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:
- User is redirected to the OAuth provider for authentication
- Provider redirects back to the application with an authorization code
- The application exchanges the code for an access token (and ID token for OIDC)
- The application fetches user information from the provider
- 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) andiss(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
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_verifiedis 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": falsewould still trigger the upsert match - A provider not returning
email_verifiedat 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
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:
- The application redirects the attacker to the OAuth provider
- The attacker authenticates with the provider (using their own credentials)
- The provider redirects back with an authorization code
- 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:
- The upsert with
:unique_emailmatches against the victim’s existing record - The victim’s
user_identitiesrecord is updated (the attacker’ssubis linked to the victim’s account) SignInPreparation.prepare/3generates a session token for user_id = victim’s ID- 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
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
subpatterns
Proof of Concept
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