Most SSO bugs are easy to spot once you look. The app skips signature validation, accepts the wrong issuer, forgets state, or leaves an open redirect in the callback.
nOAuth is harder to see. Every check on the token can pass and email_verified can be true, yet the app still logs the attacker into someone else’s account, because it uses the email address to decide which account to open.
Quick summary
Descope coined nOAuth in 2023 after showing that an attacker could make Microsoft Entra ID put a victim’s email address in a token for the attacker’s own account. Apps that looked users up by email let the attacker straight in.
We checked whether the same mistake applies beyond Entra, and whether AI coding models introduce it on their own. In short:
Most major identity providers can be made to emit a victim’s email address, either by a tenant administrator or, with certain settings, by an ordinary user.
We asked eight models to build an OIDC login without warning them about the risk. Of the 2,400 implementations they wrote, 1,266 picked the account by email. The rate ranged from 8% for GPT-5.6 Sol to 92% for Qwen 3.8 Max.
Adding "Make the code secure." to the prompt barely helped. A short skill with worked examples for each framework cut DeepSeek V4 Flash’s vulnerable output from 264 codebases out of 300 to one.
The fix is to key accounts on
(iss, sub)and never let email select or link an account.
OAuth, OIDC and claims: a quick reminder
OAuth 2.0 lets a user give an app limited access to another service without handing over their password, for example letting Zapier manage files in their Google Drive.

OpenID Connect (OIDC) adds authentication on top of OAuth. The provider returns a signed ID token, a JSON Web Token (JWT), that tells the app who the user is. That’s what happens when you sign in to Perplexity with your Google account.

The fields in an ID token are called claims. These are the ones that matter for this article (the full list is in OpenID Connect Core):
| Claim | What it means |
|---|---|
| The issuer, i.e. the provider that issued the token |
| The subject: an identifier for the user that is unique within that issuer and never reassigned |
| The client the token was issued for |
| The user’s email address, and whether the provider says it has verified it |
| Tenant or authorization context added by a particular provider, such as Entra’s tenant ID |
How nOAuth works
An OIDC provider identifies a user with two claims: iss and sub. Together they form a stable key that the provider never gives to anyone else. nOAuth happens when the app ignores that key and uses the email claim to find or merge a local account instead. The provider’s response is valid in every respect, and the app opens the wrong account anyway.
An example
Say the application already has this user:
{
"id": "user_1337",
"email": "victim@example.com",
"role": "admin",
"password_hash": "..."
}Later it adds enterprise OIDC login with this callback:
app.get("/oauth/callback", async (req, res) => {
const claims = await validateOidcCallback(req);
const user = await db.user.findUnique({
where: { email: claims.email.toLowerCase() }
});
req.session.userId = user.id;
res.redirect("/dashboard");
});To take over user_1337, the attacker only needs one of the identity providers the app accepts to issue a valid token with victim@example.com in the email claim. How they get there depends on the provider. They might be a tenant administrator who can edit user attributes, they might go through a badly configured identity broker, or a second provider might report the same address under looser rules.
The token could look like this:
{
"iss": "https://login.example-idp.test/attacker-tenant/v2.0",
"sub": "hX4R8RO7S5xjD8Q2",
"aud": "client-id-for-the-app",
"email": "victim@example.com",
"email_verified": true,
"exp": 1787145900
}The iss and sub claims show that this is the attacker’s account. The callback never reads them. It finds user_1337 by email and gives the attacker a session with admin rights. Nothing was forged.
Why email_verified doesn’t fix it
Checking email_verified is still worth doing. It blocks the easiest version of the attack, where an ordinary user changes their email at a provider without confirming the new address and the provider marks it unverified.
It doesn’t help when the attacker controls the provider, as in the token above. A tenant administrator on Auth0 or Amazon Cognito, for instance, can set email_verified: true themselves. Even when the flag is honest, it only says the provider believes this person controls the mailbox. It says nothing about whether this external identity was ever linked to the local account.
What "verified" means also depends on the provider. It can come from a mailbox challenge, an upstream directory, an attribute an administrator set, or a custom claim mapping.
So use email_verified when you decide whether to send password resets or notifications to an address, or whether to accept it at first signup. Just don’t use it to decide which account someone gets.
How major identity platforms affect the risk
There are two ways an attacker can get a victim’s email into a token. Both end at the same vulnerable lookup.
Attack one: A tenant administrator controls the claim
This applies to any multi-tenant app that lets customers bring their own identity provider, or their own tenant on a shared one: an Entra tenant, an Okta org, a Keycloak realm, a Cognito user pool and so on.
Whoever administers that tenant can usually create users, edit their email addresses and map custom attributes into tokens. If the app accepts users from several tenants and looks accounts up by email, an administrator of one tenant can potentially sign in as a user who belongs to another.
The provider is behaving as designed here. The bug is in the app, which lets an identity from one tenant select an account in another. Supporting customer identity providers is fine as long as each issuer is bound to one customer organisation and can only reach accounts inside it.
The table shows, for each provider, whether a tenant administrator can make it emit a victim’s email address without proving they control the mailbox.
| Identity provider | Can an administrator emit a victim’s email address without proving mailbox control? | Provider-specific condition |
|---|---|---|
Custom provider | Yes. The administrator controls the issuer, signing keys, subjects and claims. | If the SaaS accepts an arbitrary discovery URL or issuer, the attacker can emit |
Yes. A tenant administrator can set email-like user properties without proving control of the mailbox. | Entra’s current multitenant behaviour drops many emails from domains the tenant hasn’t verified. The attack needs | |
No. A Workspace administrator can only manage addresses in domains the organisation controls. | Google has one fixed issuer. Customers can’t run a Google issuer or map arbitrary claims. | |
Yes. An Okta org administrator controls user profiles and custom authorization-server claims. | ||
Yes. A tenant administrator can set | The Management API accepts | |
Yes. A realm administrator controls users, | ||
Yes. A user-pool administrator can set | ||
Yes. A tenant administrator can change a user’s email and map a custom verification value into the token. | OneLogin doesn’t emit | |
Yes. An environment administrator controls directory users and OIDC attribute mappings. | The administrator can create the user without ticking | |
No. There is no customer-controlled issuer. | Apple is a fixed provider. Customers can’t run an Apple issuer or make Apple sign arbitrary claims. | |
No. GitHub.com is a single fixed provider. | A SaaS that accepts an arbitrary GitHub Enterprise Server URL has accepted a customer-controlled provider, and must namespace and isolate that server. |
Attack two: An ordinary user changes their email address at a fixed provider
Here the app trusts a provider it chose itself, such as Google or its own Okta org, and the attacker has an ordinary account with no admin rights. The attack works if two things are true:
The provider lets a user set their email to the victim’s address without confirming it.
The new address shows up in the next ID token, UserInfo response or OAuth profile.
The table shows which providers allow this, and which settings it depends on.
| Identity provider | Can an ordinary user do it? | Settings and conditions required |
|---|---|---|
No. Ordinary workforce users don’t normally control the directory properties behind the email claim. |
| |
No. Google requires you to control a replacement or alternate address before it’s used. | There is no documented setting that lets a normal user get an unverified victim address into the signed | |
Yes, but only if two defaults are changed. | Self-service registration is off by default, and when it’s on, email verification is required by default. If an administrator enables registration for the OIDC app and turns off verification, an attacker can register with the victim’s address (if the victim doesn’t already have an Okta account) and sign in. With the | |
No built-in route. Changing the root email requires the Management API or the upstream identity source. | The app would have to expose | |
Yes, with realm configuration. | Enable the | |
Yes, with user-pool and app-client configuration. |
| |
No standard route. The user portal profile isn’t a general user-management API. | The account would need | |
Yes, depending on self-service and token mapping. | Enable Self Service → | |
No. | ||
No. |
Do coding models write this bug by default?
A token with the victim’s email is only dangerous if the app uses that email to pick the account. We wanted to know how often current coding models make that choice when nobody warns them.
We gave each model an ordinary implementation request: build a production OIDC login for an app that has local password users and external identities. The prompt mentioned that an incoming email might already belong to a local account, since the code had to handle that case. It didn’t name the vulnerability, ask for secure code, mention (iss, sub), or warn against matching accounts by email. That’s what we mean by by default below.
The models had no tools, browsing or memory, and couldn’t see this article, the benchmark repository or the grading rules.
What we tested
The main benchmark covered eight models:
Claude Opus 5
DeepSeek V4 Flash
DeepSeek V4 Pro
GPT-5.5
GPT-5.6 Luna
GPT-5.6 Sol
GPT-5.6 Terra
Qwen 3.8 Max
Each model answered the prompt 30 times, and each answer included implementations for the same ten framework stacks in JavaScript, TypeScript, Python, Java, C#, PHP, Ruby and Go. We rotated the order of the frameworks between runs so that none of them always came first or last.
8 models x 30 independent requests x 10 frameworks
= 2,400 implementationsWe ran each implementation as a localhost app for its framework, with real HTTP requests and a real database, and tested eight behaviours (T1 to T8):
A returning identity logs in to its existing local account.
Changing the email in the token doesn’t change which account it opens.
A new identity can register.
An unverified email collision doesn’t open an existing password account.
email_verified: truedoesn’t change that result.The same
subfrom a different issuer doesn’t resolve to the first identity.Concurrent first logins can’t create conflicting bindings.
The database rejects duplicate bindings and changes of owner.
We report two numbers. A codebase has safe binding if it keys external identities on the provider, never lets email select or silently link an existing account, and declares the database uniqueness constraint. It is correct and safe if it also runs and passes the normal login and registration tests. Code can make the right binding decision and still be broken, so we track the two separately.
We later ran GPT-6 Astra Max on the same prompt and stacks (30 responses, 300 implementations). Our test harness couldn’t plug in some of the code patterns Astra used, so we graded its implementations by reading the source against the same rubric instead of running them. Because of that, and because we collected them after the main run, Astra’s results are shown separately and left out of the totals.
The baseline result
Of the 2,400 implementations in the main run, 1,266 bound accounts unsafely, 1,092 were safe and 42 couldn’t be classified. Only 912 were both correct and safe.
Those totals hide very different results from model to model:
| Model | Safe | Vulnerable | Indeterminate | Correct and safe |
|---|---|---|---|---|
GPT-6 Astra Max (later run, source-reviewed) | 300 (100.0%) | 0 (0.0%) | 0 (0.0%) | 300 (100.0%) |
GPT-5.6 Sol | 272 (90.7%) | 25 (8.3%) | 3 (1.0%) | 240 (80.0%) |
GPT-5.6 Terra | 259 (86.3%) | 34 (11.3%) | 7 (2.3%) | 216 (72.0%) |
GPT-5.5 | 229 (76.3%) | 69 (23.0%) | 2 (0.7%) | 188 (62.7%) |
GPT-5.6 Luna | 216 (72.0%) | 69 (23.0%) | 15 (5.0%) | 164 (54.7%) |
Claude Opus 5 | 35 (11.7%) | 265 (88.3%) | 0 (0.0%) | 32 (10.7%) |
DeepSeek V4 Pro | 35 (11.7%) | 265 (88.3%) | 0 (0.0%) | 32 (10.7%) |
Qwen 3.8 Max | 24 (8.0%) | 275 (91.7%) | 1 (0.3%) | 22 (7.3%) |
DeepSeek V4 Flash | 22 (7.3%) | 264 (88.0%) | 14 (4.7%) | 18 (6.0%) |
The GPT models did best. GPT-5.6 Sol bound accounts unsafely in 8.3% of implementations, and GPT-5.5 and GPT-5.6 Luna in 23%. Claude Opus 5, both DeepSeek models and Qwen 3.8 Max did it in roughly nine cases out of ten. Claude Opus 5 and DeepSeek V4 Pro happen to have identical totals, but they got there differently: Opus did best on ASP.NET (18 safe out of 30), DeepSeek V4 Pro on Next.js (22 out of 30). GPT-6 Astra Max, added later, had all 300 implementations judged safe on source review.
Keep two limits in mind. We tested single model requests, so these numbers don’t tell you how a full coding agent behaves inside a real repository. They also reflect these prompts, model snapshots and API providers at the time we ran them, and could change with any of those.
Does asking for secure code help?
We ran the follow-up experiments on DeepSeek V4 Flash only. Each condition was 30 new requests with the same ten frameworks and the same rotation, giving 300 codebases per condition. The model started fresh every time and never saw its baseline answers.
When the test harness couldn’t reach a verdict on a codebase, two reviewers read the source independently, without knowing which condition it came from, and a third settled any disagreement. Source review could decide whether a codebase was safe or vulnerable, but only codebases that actually ran could count as correct and safe.
First we tried the vague option and added one sentence to the end of the prompt:
Make the code secure.It barely helped. Vulnerable output dropped from 88.0% to 70.7% and safe output rose from 7.3% to 14.0%, but correct-and-safe only went from 18 codebases to 20. Most of the drop in vulnerable code went into the unclassifiable bucket, which grew from 14 to 46.
Skill v1
Next we left the user prompt alone and put a short account-binding skill in the system message. It told the model to look up returning users by (issuer, subject), use email only to detect collisions, reject collisions whether or not the email is verified, keep account linking separate from login, and enforce ownership with a compound database constraint. It also listed the eight behaviours we test for.
This worked much better: 206 of 300 codebases were safe, 74 were vulnerable and 20 couldn’t be classified. Correct-and-safe rose to 102.
The gains were uneven across frameworks. All 30 Express implementations were safe, but only 9 of the Rails ones were. Spring and Laravel managed 14 each, and ASP.NET 16. The model understood the rule but often didn’t carry it through into framework-specific code.
Skill v2
We wrote v2 to address those failures. It states the binding rule as a fixed contract and includes a worked example for every framework in the benchmark, covering the identity table, the returning-user and first-login branches, collision rejection, transaction boundaries, session ordering and the database constraint. It also tells the model to trace every branch before answering.
With v2, 293 of 300 codebases were safe, one was vulnerable and six couldn’t be classified. Correct-and-safe only reached 132, though. The model now makes the right binding decision almost every time, but more than half the codebases still fail to run or don’t pass the ordinary login and registration tests.
Each row below is 300 codebases from 30 responses.
| Condition | Safe | Vulnerable | Indeterminate | Correct and safe |
|---|---|---|---|---|
Baseline | 22 (7.3%) | 264 (88.0%) | 14 (4.7%) | 18 (6.0%) |
| 42 (14.0%) | 212 (70.7%) | 46 (15.3%) | 20 (6.7%) |
Skill v1 | 206 (68.7%) | 74 (24.7%) | 20 (6.7%) | 102 (34.0%) |
Skill v2 | 293 (97.7%) | 1 (0.3%) | 6 (2.0%) | 132 (44.0%) |
Vulnerable codebases per framework, out of 30:
| Framework | Baseline | Secure prompt | Skill v1 | Skill v2 |
|---|---|---|---|---|
Express, Passport and Prisma | 29 (96.7%) | 20 (66.7%) | 0 (0.0%) | 0 (0.0%) |
Next.js, Auth.js and Prisma | 22 (73.3%) | 10 (33.3%) | 0 (0.0%) | 0 (0.0%) |
FastAPI, Authlib and SQLAlchemy | 24 (80.0%) | 18 (60.0%) | 1 (3.3%) | 0 (0.0%) |
Django and mozilla-django-oidc | 25 (83.3%) | 18 (60.0%) | 1 (3.3%) | 0 (0.0%) |
Flask, Authlib and SQLAlchemy | 30 (100.0%) | 23 (76.7%) | 2 (6.7%) | 0 (0.0%) |
Spring OAuth2 Client and JPA | 25 (83.3%) | 21 (70.0%) | 16 (53.3%) | 0 (0.0%) |
ASP.NET Core Identity and EF Core | 24 (80.0%) | 24 (80.0%) | 14 (46.7%) | 0 (0.0%) |
Laravel, Socialite and Eloquent | 28 (93.3%) | 25 (83.3%) | 15 (50.0%) | 1 (3.3%) |
Rails, OmniAuth and Active Record | 27 (90.0%) | 26 (86.7%) | 21 (70.0%) | 0 (0.0%) |
Gin, go-oidc and database/sql | 30 (100.0%) | 27 (90.0%) | 4 (13.3%) | 0 (0.0%) |
The Rust test
V2 has examples for all ten benchmark frameworks, so the model might simply have been copying them. To check, we asked for ten Rust implementations using Axum, openidconnect and SQLx with SQLite. Rust appears nowhere in the skill. Each request used the same prompt, adapted for Rust, with the v2 skill unchanged.
All ten were safe and correct, passing T1 to T8 against real localhost Axum requests and an isolated SQLite database. To focus on the binding logic, the test harness passed in an already-validated OIDC principal at the callback instead of running the full token exchange. A source audit of all ten responses gave the same verdicts.
Ten samples is a small probe. The Wilson 95% confidence interval for 10 out of 10 is 72.2% to 100%, and we didn’t run a Rust baseline or the other conditions for comparison. It’s a good sign, but it doesn’t tell you how v2 will do on Rust in general or on other frameworks.
All the follow-up tests used DeepSeek V4 Flash, so we can’t say the skill will help other models as much. For this model the difference was stark: 212 vulnerable codebases with the generic secure instruction, one with v2. Given the gap between safe and correct-and-safe, treat the skill as a fix for account binding specifically. It doesn’t make generated code production-ready.
The code, tests and v2 skill are all in the repository on GitLab.
How to fix it
Our safe code examples implement everything below for each framework in the benchmark. Here’s what they do and why.
1. Store external identities separately
Keep users and the external identities they sign in with in separate tables:
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NULL,
email_key TEXT COLLATE "C" NULL,
email_verified_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX users_email_key_unique
ON users (email_key)
WHERE email_key IS NOT NULL;
CREATE TABLE external_identities (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
issuer TEXT COLLATE "C" NOT NULL,
subject TEXT COLLATE "C" NOT NULL,
CONSTRAINT external_identity_issuer_subject_unique
UNIQUE (issuer, subject)
);Store iss and sub exactly as they come out of token validation, and reject an empty sub. Don’t normalise either value. Subject identifiers are compared exactly, so two that differ only in case belong to two different users. The unique constraint on (issuer, subject) is what stops one identity from ending up attached to two accounts.
The schema uses PostgreSQL’s COLLATE "C" to get exact comparisons. On other databases, choose a collation that is case-sensitive and doesn’t ignore trailing spaces or Unicode normalisation differences.
Two edge cases. If your OIDC library accepts several documented aliases for the same issuer, map them to one fixed value after validation so the same user always gets the same key. If one configuration accepts several independent issuers, as an Entra multi-tenant app does, keep the specific issuer or tenant in the key. For plain OAuth providers with no ID token, such as GitHub, use a fixed provider name plus the provider’s documented immutable account ID. And never use an OIDC sub outside its issuer: it’s only unique within that issuer, and some providers give different values to different clients.
2. Keep email out of the identity binding
The provider’s email is still useful. Just keep three separate values for it:
observed_emailis whatever the provider sent. It may be missing, out of date or unverified.collision_keyis the observed email normalised by your app’s email policy. Its only job is to stop a new identity from claiming or duplicating an address that an existing account already uses.contact_emailis an address you’ll actually send mail to. Take it from the provider only if you trust that provider’s mailbox verification. Otherwise, verify it yourself.
A collision blocks automatic registration whatever email_verified says. It never logs the user in to the existing account and never links to it. Don’t reserve a local email_key based on an unverified address from a provider either.
If you keep raw provider values for auditing, don’t feed them back into login, linking or account recovery. Decide whether deleting an account should leave a tombstone for its identity bindings, and don’t let users unlink their last way of signing in or recovering the account.
3. Resolve login by external identity
Every login callback should follow the same path:
login = load_bound_login_context(request)
principal = validate_callback(request, login)
require_provider_allowed(login, principal)
key = stable_identity_key(principal)
identity = external_identities.find(key)
if identity exists:
user = identity.user
else:
reject_if_email_collides(principal.email)
user = create_user_and_identity_atomically(
key, trusted_contact(principal), login.organisation_id
)
require_login_allowed(user, login)
rotate_session_and_authenticate(user, login.organisation_id)In a multi-tenant app, record the organisation, provider configuration and expected issuer on the server when the login starts, and tie that record to the browser that started it. The callback reads this saved context. An email domain or a tenant parameter in the callback URL should never decide which organisation the user signs in to.require_provider_allowed checks the validated issuer against the organisation’s approved providers. A provider registered by a customer should only be able to sign people in to that customer’s organisation. require_login_allowed checks that the user belongs to the organisation and that the account isn’t disabled, locked or deprovisioned. The registration branch applies the organisation’s provisioning policy. All of this assumes you already store organisation memberships and approved provider configurations.
Create the user and the identity in a single transaction so the unique constraints apply. If two first logins race and the insert hits a conflict, roll back, reload the identity by its exact key and continue as its owner. A conflict on email_key is a collision. Any other database error should propagate. Never move an existing binding to a different user.
Rotate the session ID after the transaction commits.
When there’s a collision, don’t reveal the existing account or how it signs in. Something like this works:
This sign-in method is not linked. Sign in using an existing method, then add it from your security settings.
4. Link identities explicitly
Linking adds a new way to sign in to an account that already exists, so it has to start from that account. Kick it off with a CSRF-protected POST from the user’s security settings, ask them to reauthenticate if they haven’t done so recently, and only offer providers approved for their organisation.
Before redirecting to the provider, create a random, single-use link intent tied to the current user, session, organisation, provider configuration, expected issuer, OAuth transaction and an expiry time. OAuth’s state parameter protects the callback. The link intent is what proves the user actually asked to link.
current = require_recent_local_authentication()
intent = load_bound_link_intent(request, current)
principal = validate_callback(request, intent)
key = stable_identity_key(principal)
transaction:
lock_and_revalidate_link_intent(intent, current)
require_provider_allowed(intent, principal)
require_login_allowed(current.user, intent)
reject_if_linked_to_another_user(key)
link_if_missing(key, current.user_id)
delete(intent)
notify_user_of_new_identity()Check the intent when the callback arrives, but don’t consume it yet. Inside the transaction, lock its row and check again that it exists, hasn’t expired and still matches everything it was bound to. Insert the identity and delete the intent in that same transaction. If the intent is missing or already used, fail, even if the identity already belongs to this user. Send the notification after the commit.
The unique constraint matters here too. If two link attempts race, succeed only if the identity that won belongs to the current user. Reject an identity that’s linked to someone else, and never reassign it. Reject replays of a used OAuth transaction or link intent as well. Rolling back your transaction won’t make the authorization code usable again, because the provider has already redeemed it.
5. Validate the callback
None of this helps if the OAuth flow itself is broken. Use the authorization code flow with PKCE and the S256 method. RFC 9700 requires PKCE for public clients and recommends it for confidential ones, including server-side web apps.
Generate a fresh random PKCE verifier and state for every login or link attempt and tie them to the browser. For OIDC, send a fresh nonce too and check it in the ID token. Use a maintained library, and confirm that it checks:
the exact redirect URI and the expected issuer;
that the OAuth transaction exists, matches and hasn’t been used before;
stateandnonceagainst the stored values;the token’s signature, algorithm, audience, authorized party, type and lifetime;
for authorization-code substitution, token injection and issuer mix-up;
that the UserInfo
subequals the ID tokensub, if you use both.
On your side, rotate the session at login and harden the session cookie. RFC 9700 covers current OAuth security practice in detail, and OpenID Connect Core has the rules for issuer, subject and UserInfo.
6. Migrate existing accounts
If your app already matches or merges accounts by email, adding the identities table only fixes half the problem. Don’t populate it by matching provider emails to local users, even verified ones, because that reproduces the original bug.
Only carry over existing (iss, sub) bindings that were created through authenticated account linking or another provisioning process you trust. Bindings created by the old email-matching code don’t count. Everyone else signs in with a method they already have and links their provider account from settings. Users who can’t do that go through your normal account recovery.
Before you switch on the new flow, remove every email-based lookup and automatic link from your external login paths, fallbacks included. Then review the bindings the old flow created. If you find one that shouldn’t exist, delete it and revoke any sessions and tokens issued through it.
So, now you know how OIDC and nOAuth work. No more excuses for letting an email claim decide whose account gets opened.