HB Updated Jun 21, 2026

Email is Not Identity: CVE-2026-49757 — Account Takeover in AshAuthentication's OAuth2/OIDC Strategies

On June 15, 2026, a critical vulnerability was disclosed in AshAuthentication — the authentication extension for the Ash Framework in Elixir — that allows an unauthenticated attacker to take over any local user’s account through the OAuth2 or OpenID Connect sign-in flow. The flaw earned a CVSS 4.0 base score of 9.2 (Critical) and carries a CISA SSVC rating of “total” technical impact with confirmed proof-of-concept exploitation.

The root cause is both simple and devastating: AshAuthentication resolved OAuth2/OIDC callbacks to local user accounts by matching the email claim rather than the OpenID Connect iss/sub claim pair. This means anyone who can register an account on an accepted OAuth provider using a victim’s email address — even with email_verified: false — can sign in as that victim.

This is a textbook violation of OpenID Connect Core §5.7, which explicitly states that only the sub (subject) and iss (issuer) claims, used together, may serve as a stable identifier for an end-user. Email addresses are mutable, reusable, and never guaranteed unique across providers.


Vulnerability Classification

Field Value
CVE ID CVE-2026-49757
GHSA GHSA-777c-2fxx-qr28
CVSS 4.0 Score 9.2 — Critical
CVSS 4.0 Vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CWE CWE-290 — Authentication Bypass by Spoofing
SSVC (CISA) Exploitation: PoC · Automatable: Yes · Technical Impact: Total
Affected Package ash_authentication (Elixir / Erlang ecosystem)
Affected Versions >= 0.1.0, < 4.14.0 and >= 5.0.0-rc.0, < 5.0.0-rc.10
Patched Versions 4.14.0, 5.0.0-rc.10
Published June 15, 2026
Reporter jarlah

Background: AshAuthentication and the Ash Framework

Ash Framework is a declarative resource framework for Elixir that provides a unified DSL for defining data models, business logic, and APIs. It is to Elixir what ActiveRecord is to Ruby on Rails — but more ambitious, covering not just data layers but also policies, validations, and code generation.

AshAuthentication extends Ash with authentication capabilities: password-based sign-in, OAuth2/OIDC provider integration, magic links, and session management. It is the standard authentication solution for teams building applications on the Ash Framework, used across Phoenix LiveView applications and API backends alike.

The library supports configurable strategies — modules that implement specific authentication methods. The two most complex are:

  • OAuth2 strategy — handles generic OAuth2 providers (GitHub, Google, Auth0, Slack, Apple, and custom providers)
  • OIDC strategy — extends OAuth2 with OpenID Connect support, processing ID tokens and standardized claims

Both strategies follow the same high-level flow:

  1. User is redirected to the OAuth provider for authentication
  2. Provider redirects back to the application with an authorization code
  3. The application exchanges the code for an access token (and ID token for OIDC)
  4. The application fetches user information from the provider
  5. The application resolves the user information to a local account

Step 5 is where CVE-2026-49757 lives.


The Core Flaw: Email as Primary Key

OpenID Connect Core §5.7 — The Spec

Section 5.7 of the OpenID Connect Core 1.0 specification defines the stability requirements for claims. It states:

The sub (subject) and iss (issuer) Claims, used together, are the only Claims that a Client MAY rely upon as a stable identifier for the End-User… The Client MUST NOT use the email Claim as a unique identifier for the End-User.

The reasoning is straightforward:

  • Email addresses are mutable. A user can change their email on any provider at any time.
  • Email addresses are reusable. If a user deletes their account on a provider, their email may become available for re-registration by someone else.
  • Email addresses are not unique across providers. Two different providers may have accounts with the same email address, belonging to different people.
  • email_verified is not a guarantee of identity. Even if a provider claims to have verified an email, the verification was done on the provider’s terms — not yours.

The iss/sub pair, by contrast, is guaranteed to be globally unique and stable. The iss claim identifies the provider, and the sub claim identifies the user within that provider. Together, they form a permanent, unforgeable link between an OAuth identity and the provider that issued it.

How AshAuthentication Violated This

When an OAuth2 or OIDC callback arrived, AshAuthentication’s IdentityChange.change/3 function processed the user information from the provider. The key operation was an upsert — a database insert-or-update that either created a new user identity record or updated an existing one.

The upsert was configured with upsert_identity: :unique_email, which means:

# Simplified representation of the vulnerable upsert
UserIdentity
|> Ash.Changeset.for_create(:upsert, %{
  strategy: :oauth2,
  uid: user_info["sub"],
  user_info: user_info,
  access_token: token,
  refresh_token: refresh_token
}, upsert_identity: :unique_email)

When the upsert identity is :unique_email, the database engine looks for an existing record matching on the email field. If the OAuth callback contained victim@example.com, the query was effectively:

SELECT * FROM user_identities 
WHERE email = 'victim@example.com'

If a record was found — meaning the victim had previously registered with that email — the upsert would update that record (potentially re-pointing its user_id) and return the victim’s account. The subsequent SignInPreparation.prepare/3 function would then create a session token for that account.

The sub claim from the attacker’s OAuth provider was stored but never used for lookup. It was treated as metadata rather than as the primary key it should have been.

The email_verified Gap

The email_verified claim was part of the user information payload but was not checked before performing the email-based lookup. This means:

  • A provider returning "email_verified": false would still trigger the upsert match
  • A provider not returning email_verified at all would default to an implicit trust
  • An attacker who had never verified the victim’s email on the malicious provider could still execute the attack

Attack Scenario Walkthrough

CVE-2026-49757 Attack Flow

Prerequisites

  • The target application uses a vulnerable version of AshAuthentication (before 4.14.0 or 5.0.0-rc.10)
  • The application has at least one OAuth2 or OIDC strategy configured
  • The attacker knows the victim’s email address (trivially obtainable)
  • The attacker can register an account on an accepted OAuth provider using the victim’s email

Step 1: Reconnaissance

The attacker identifies a target application built with AshAuthentication. They observe that the application supports OAuth2 sign-in (e.g., “Sign in with Google” or “Sign in with GitHub”). They note the victim’s email address — perhaps an administrator’s email found in public records, commit history, or social media.

Step 2: Provider Registration

The attacker registers an account on an accepted OAuth provider using the victim’s email address. Depending on the provider, this may involve:

  • Providers with lax email verification — the attacker simply registers with the victim’s email and no verification is required
  • Providers with email verification — the attacker uses a temporary email interception service, or exploits email reclamation (if the victim previously had an account on the same provider and deleted it)
  • Self-hosted providers — if the target accepts custom OIDC providers, the attacker stands up their own provider instance

The key insight is that the attacker does not need the victim’s password on the provider. They need an account on any accepted provider that returns the victim’s email in the user info response.

Step 3: OAuth Sign-In

The attacker initiates the OAuth2 sign-in flow on the target application using their newly created provider account:

  1. The application redirects the attacker to the OAuth provider
  2. The attacker authenticates with the provider (using their own credentials)
  3. The provider redirects back with an authorization code
  4. AshAuthentication exchanges the code for a token and fetches user info

The user info payload now contains:

{
  "email": "victim@example.com",
  "email_verified": false,
  "sub": "attacker-provider-account-id",
  "name": "Attacker"
}

Step 4: The Account Takeover

AshAuthentication’s IdentityChange.change/3 processes the callback:

  1. The upsert with :unique_email matches against the victim’s existing record
  2. The victim’s user_identities record is updated (the attacker’s sub is linked to the victim’s account)
  3. SignInPreparation.prepare/3 generates a session token for user_id = victim’s ID
  4. The attacker receives a valid session cookie or token for the victim’s account

The attacker is now logged in as the victim — with all their privileges, data access, and administrative capabilities.


Root Cause Analysis

Vulnerable vs Fixed User Resolution

The vulnerability has three interrelated root causes:

1. Upsert Identity on Email Instead of (strategy, sub)

The user_identities resource was configured with a unique constraint on email, making it the upsert identity. This meant that any OAuth callback with a matching email would resolve to the same local user, regardless of which provider issued the identity or what sub claim was attached.

The correct unique constraint should have been (strategy, uid) — one provider identity maps to exactly one local user, permanently. This is what the fix implements.

2. No email_verified Enforcement

Even if email-based matching were intentional for convenience (e.g., auto-linking verified emails to existing accounts), the library should have enforced the email_verified claim. The vulnerable code treated all emails equally, whether verified or not.

3. Silent Re-pointing of User Identities

The upsert not only matched on email but could also re-point the user_id field of an existing identity record. This meant that even if a user identity was already linked to one local account, an OAuth callback with a matching email could silently transfer that identity to a different account.


The Fix

The patch, released in versions 4.14.0 and 5.0.0-rc.10, introduces a fundamental reworking of the OAuth2/OIDC user resolution pipeline. The changes span multiple commits and touch several core modules.

New UserResolver Module

The most significant change is the introduction of a dedicated UserResolver module that replaces the email-based upsert with a (strategy, sub) lookup:

# New resolution logic (simplified)
def resolve(strategy, user_info, context) do
  case find_identity_by_sub(strategy, user_info["sub"]) do
    {:ok, identity} ->
      # Known provider identity — return the linked local user
      {:ok, identity.user_id}

    :not_found ->
      # Unknown sub — check email match policy
      handle_untrusted_email(strategy, user_info, context)
  end
end

If the (strategy, sub) pair is found in the user_identities table, the linked user is returned. If not, the new on_untrusted_email_match policy applies.

on_untrusted_email_match Option

This new configuration option controls what happens when an OAuth callback presents an email that matches an existing local account, but the sub claim is unknown:

Mode Behavior
:reject (default) The login is denied. An error is returned. No identity is created.
:confirm A confirmation token is sent to the email address. The identity is only linked after the email owner confirms.

The :confirm mode is the most user-friendly: it preserves the convenience of auto-linking accounts by email while preventing takeover. The email owner must prove they control the address before the OAuth identity is linked.

trust_email_verified? Option

A separate boolean option controls whether the library trusts the provider’s email_verified claim. When true, the library will auto-link an OAuth identity to a local account by email — but only if the provider explicitly returns email_verified: true.

This option defaults to true for well-known providers that have robust email verification (GitHub, Google, Auth0, Slack, Apple) and false for all custom/generic OAuth2 strategies. This is a pragmatic compromise: it preserves convenience for trusted providers while defaulting to safety for everything else.

Identity Unique Key Change

The unique constraint on user_identities was changed from (strategy, uid, user_id) to (strategy, uid). This ensures that:

  • One provider identity belongs to exactly one local user — permanently
  • An OAuth callback can never re-point an existing identity to a different account
  • The (strategy, sub) lookup is the only path to resolving an OAuth callback to a local user

Upsert Field Restrictions

Even when an upsert does match (on a known (strategy, sub) pair), the update is now restricted to refreshing only token fields (access_token, refresh_token). The user_id field is never updated on conflict, preventing any silent re-pointing.

Compile-Time Warnings

The library now emits compile-time warnings for OAuth2 strategies configured without an identity_resource, alerting developers that their strategy is vulnerable to account hijacking.


Remediation

Immediate Action: Upgrade

If you are using AshAuthentication, upgrade immediately:

# For hex.pm users
mix deps.update ash_authentication

# Verify the version
mix deps | grep ash_authentication
# Should show 4.14.0+ or 5.0.0-rc.10+

Configuration Review

After upgrading, review your OAuth2/OIDC strategy configurations. The default on_untrusted_email_match: :reject is safe, but you should explicitly verify:

# In your resource definition
strategies do
  oauth2 :google do
    # Explicitly set the email match policy
    on_untrusted_email_match :reject  # or :confirm for auto-linking with verification

    # For custom providers, explicitly set trust_email_verified?
    trust_email_verified? false  # Default for non-registered providers

    # ...
  end
end

Audit Existing User Identities

If you have been running a vulnerable version, you should audit your user_identities table for suspicious entries:

-- Find identities where the provider email doesn't match the local user's email
SELECT ui.*, u.email as local_email, ui.user_info->>'email' as provider_email
FROM user_identities ui
JOIN users u ON ui.user_id = u.id
WHERE ui.user_info->>'email' != u.email;

-- Find multiple identities pointing to the same user from different providers
SELECT user_id, COUNT(DISTINCT strategy) as provider_count
FROM user_identities
GROUP BY user_id
HAVING COUNT(DISTINCT strategy) > 1;

Look for:

  • Identities where the provider email doesn’t match the local user’s email
  • Multiple provider identities pointing to the same user that were created in a short time window
  • Identities from unexpected providers or with unusual sub patterns

Proof of Concept

https://github.com/Hunt-Benito/ash-authentication-oauth2-oidc-account-takeover-cve-2026-49757-email-based-user-matching

The PoC demonstrates the vulnerability using a simulated AshAuthentication-style OAuth callback handler. It shows the complete attack chain: victim registration, attacker email spoofing on a provider, and the account takeover via OAuth callback.

Key code — the vulnerable email-matching logic:

# vulnerable_handler.py — Simulates AshAuthentication's IdentityChange.change/3
def resolve_user_by_email(db, user_info, strategy_name):
    """
    Vulnerable: matches OAuth callback to local user by email.
    This is exactly what AshAuthentication did before the fix.
    """
    email = user_info.get("email")

    # Upsert identity on :unique_email — the vulnerable lookup
    existing = db.execute(
        "SELECT * FROM users WHERE email = ?", (email,)
    ).fetchone()

    if existing:
        user_id = existing["id"]
        # Link the attacker's sub to the victim's account
        db.execute(
            "INSERT OR REPLACE INTO user_identities "
            "(strategy, uid, user_id, user_info) VALUES (?, ?, ?, ?)",
            (strategy_name, user_info["sub"], user_id, json.dumps(user_info))
        )
        db.commit()
        return {"user_id": user_id, "email": email}  # Returns VICTIM's account

    return None

The exploit script:

# Run the exploit (Python 3.8+, no dependencies required)
$ python3 exploit.py

# Expected output (Phase 1 — vulnerable handler):
[*] Victim registers on the target application:
    Created local user: id=1, email=admin@target-app.com, role=admin

[*] Attacker registers on an OAuth provider with the victim's email:
    Provider returns: {
        "email": "admin@target-app.com",
        "email_verified": false,
        "sub": "keycloak-attacker-fake-789"
    }

[*] Attacker initiates OAuth2 login via the malicious provider:
    → GET /auth/keycloak/callback
    [handler] MATCH FOUND via email upsert:
    [handler]   user_id:    1
    [handler]   role:       admin

[!] [!] [!] ACCOUNT TAKEOVER SUCCESSFUL [!] [!] [!]
[!] Attacker is now logged in as user_id=1 (admin)
[!] Session token: <valid session token>

The PoC then demonstrates the fix across all three policies (:reject, :confirm, and trust_email_verified?) showing how each blocks the attack. See the GitHub repo above for the full code and step-by-step instructions.


Why This Matters Beyond AshAuthentication

CVE-2026-49757 is not an isolated incident. The pattern of using email as a primary key for OAuth user resolution has caused similar vulnerabilities across the industry:

  • OAuth2 account linking flaws — Multiple authentication libraries and frameworks have historically allowed account takeover via email-based matching in OAuth sign-in flows
  • OWASP Top 10 — This pattern falls under A07:2021 (Identification and Authentication Failures)

The lesson is universal: email is a contact method, not an identity. When implementing OAuth2 or OIDC integration, always resolve users by the (issuer, subject) pair — never by email. If you must offer email-based account linking for convenience, require an out-of-band verification step (the :confirm mode in the AshAuthentication fix) before linking.

For Elixir developers specifically, AshAuthentication’s fix provides a well-designed model worth studying even if you use a different framework. The on_untrusted_email_match policy (:reject / :confirm) and the trust_email_verified? per-provider flag are patterns that translate cleanly to any language.


SOURCES

NIST National Vulnerability Database — CVE-2026-49757: https://nvd.nist.gov/vuln/detail/CVE-2026-49757

GitHub Security Advisory GHSA-777c-2fxx-qr28: https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-777c-2fxx-qr28

AshAuthentication Fix Commit (UserResolver + policy options): https://github.com/team-alembic/ash_authentication/commit/64530644f9b37ebb76ca14aeb83a77597a0034b7

AshAuthentication Fix Commit (identity key change + compile warnings): https://github.com/team-alembic/ash_authentication/commit/728b8d28c1b5f465fa1116ef044a815300fc733d

AshAuthentication Hex Package: https://hex.pm/packages/ash_authentication

Ash Framework Documentation: https://hexdocs.pm/ash/

OpenID Connect Core 1.0 Specification — Section 5.7 (Claim Stability): https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability

OWASP Top 10 — A07:2021 Identification and Authentication Failures: https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/

CVE.org — CVE-2026-49757: https://www.cve.org/CVERecord?id=CVE-2026-49757