OAuth2 Misimplementation: Why a Valid ID Token Can Still Log In the Wrong Person
Django Security Series β Post 17 | Series III: Authentication & Session
OWASP A07:2025 β Authentication Failures | Reading time: ~15 min
π§ͺ Run it yourself. This attack ships as a runnable lab in django-security-lab, with an identity provider of its own. Give the attacker's provider account the victim's email, sign in with it, and one callback opens the victim's account while the other answers 403.
"Log in with Google" is a delegation. A Django app that offers it lets someone else check the password, an identity provider such as Google, GitHub or Microsoft, and gets back an answer about who signed in. Two standards carry that exchange. OAuth 2.0 (RFC 6749) was designed to let an app use something of yours, such as your GitHub repositories, without ever seeing your password; it answers the question "what may this app do?", not "who is this person?". OpenID Connect (OIDC) is a thin layer on top of OAuth that adds the second answer: along with the access, the provider sends an ID token, a JWT (Post 16) it signs to say who the user is. Google and Microsoft sign-in are OIDC; some providers, GitHub among them, use plain OAuth and let the app ask their API who the user is. Most Django projects get all of this from django-allauth or social-auth-app-django.
The appeal is easy to see. There is no password table to protect, no reset flow to build (Post 14), and often a second factor the provider already enforces. The cost is that the login now has two halves, and only one of them belongs to the provider. The provider authenticates the person. The callback view, the URL on your site the provider sends the user back to, decides which account in your database that person gets, and every check behind that decision runs in your code or in the library you configured. When one of them is missing, the provider's half still works perfectly, and the attacker still ends up in somebody else's account.
This post follows one of those decisions: which part of the provider's answer the callback uses to find the account.
The Attack: What It Is and How It Works
Almost every "Log in withβ¦" button on a server-rendered Django site runs what OAuth calls the authorization code flow. When the user clicks, your app sends the browser to the provider with a few details in the URL: which app is asking (client_id), where to send the user back afterwards (redirect_uri, your callback view) and what the app wants access to (the scopes). The user signs in at the provider and approves, and the provider sends the browser back to your callback with a one-time code. Your server then trades that code, together with the app's client secret, for tokens: an access token and, with OIDC, an ID token, the provider's signed statement about who just signed in.
That statement is a set of claims. iss says which provider issued it. sub, the subject, is the provider's own identifier for the person: an opaque value like 248289761001 that never changes and is never given to anyone else. Next to them come the profile claims, such as email, name and preferred_username. The callback now has to pick the claim that finds the account in your database, and email is the obvious candidate, because your local accounts are already keyed on it. It is also the wrong one. The OpenID Connect specification says so directly (Core Β§5.7): email, phone_number, preferred_username and name "MUST NOT be used as unique identifiers", and the combination of iss and sub is "the only guaranteed unique identifier". A profile claim describes the account at the provider. It can change, it can be reassigned, and some providers let the account holder type in an email address nobody ever checks.
Follow what that means for one user. Alice has had an account on your site for years, with the address alice@example.com and a password. You add "Log in with" a provider, and to make the switch painless the callback looks for a local account with the same email as the ID token and logs the person into it. The attacker now needs only one thing: a provider that will sign a token saying email: alice@example.com for an account the attacker controls. A provider that lets account holders, or the administrator of an organisation they run, set an address without proving they own it will do exactly that. So the attacker opens an account there, sets its email to Alice's address, and clicks "Log in withβ¦" on your site. The provider authenticates the attacker correctly, with the attacker's own password, and signs a genuine token about the attacker's account, carrying Alice's address. Your callback reads the email, finds Alice's row, and logs the attacker into it. Alice may never have heard of that provider. No password was guessed and no token was forged.
Nothing in that chain is broken except the last lookup.
The callback can go wrong in two other ways. A callback that does not check state, the random value an app sends with the sign-in and expects to get back, can be made to finish a sign-in the attacker started; on a "connect your account" page, that links the attacker's provider account to the victim's. And an app that accepts an ID token from the browser without checking aud, the client the token was issued to, also accepts tokens the same provider issued for any other app. allauth's defaults handle both.
Real-World Incidents
nOAuth β the Azure AD email claim (disclosed by Descope and Microsoft, June 20, 2023)
Descope, an identity vendor, found that Azure AD (now Microsoft Entra ID) put an email claim in its tokens that nobody had verified. Microsoft's advisory explains why: "AAD users without a provisioned mailbox can have any email address set for their Mail (Primary SMTP) attribute." Anyone can create a tenant, and a tenant's administrator controls that attribute for its users. So the attacker creates a tenant, sets their own user's email to the victim's address, and clicks "Log in with Microsoft" on an app that accepts accounts from any tenant and identifies users by email. Microsoft signs a perfectly genuine token saying the user's email is the victim's. If the app then merges that login into the existing local account with the same address, the attacker is in the victim's account, and, as Descope pointed out, the victim does not need a Microsoft account at all. Descope reported it to Microsoft on April 11, 2023, notified affected organisations that month, and told two authentication providers that merged accounts this way; both confirmed and fixed it within days in early May. On June 20, 2023, Microsoft and Descope disclosed it together. Microsoft deployed mitigations to "omit token claims from unverified domain owners for most applications", and its guidance to developers is one line: applications "should never use the email claim for authorization due to its mutability and non-uniqueness."
The class is not confined to large providers, or to apps that write their own callback. In django-allauth's own release notes, the Okta and NetIQ providers "were using preferred_username as the identifier for third-party provider accounts", a value that "may be mutable and should therefore be avoided for authorization decisions"; 65.13.0 (October 31, 2025) moved them to sub, and the fix was published as CVE-2025-65431 (GHSA-8m3c-c723-h4p4, rated moderate). Eleven months later, 65.19.6 (September 30, 2026) fixed the same thing in the Bitbucket and Pinterest providers, with the consequence spelled out ("If a username changed and was then claimed by another user, that user could log in to the original owner's account"), and 65.19.7, the next day, in the two Tumblr providers. That is five providers in three releases of the library most Django projects use for exactly this job. For a Django developer the lesson is narrower than "keep allauth up to date", though that matters: the identifier is a decision someone has to make for every provider, and the safe answer is the one the provider never reassigns: sub with its iss for OpenID Connect, the provider's permanent account ID for plain OAuth, as the Bitbucket, Pinterest and Tumblr fixes chose.
MITRE ATT&CK's closest fit is T1078 β Valid Accounts: the attacker ends up operating a legitimate account, logged in through the front door. The contrast with Post 16 is instructive. There, the attacker replayed a token stolen from its owner (T1550.001). Here nothing is stolen or forged; the identity provider signed a true statement, and the app drew the wrong conclusion from it. That is also why it is hard to see afterwards. The app's own log records a successful social login by the victim.
Sources: Descope β nOAuth: How Microsoft OAuth Misconfiguration Can Lead to Full Account Takeover (June 20, 2023) Β· MSRC β Potential Risk of Privilege Escalation in Azure AD Applications (June 20, 2023) Β· GHSA-8m3c-c723-h4p4 β django-allauth's Okta and NetIQ implementations used a mutable identifier for authorization decisions
Django's Default Protections
Django has no OAuth client of its own, so the defaults that matter are the library's. Everything below was checked against django-allauth 65.20.1 and social-auth-app-django 7.2.0, on Django 5.2.
Both libraries find accounts by the provider's identifier, not by email. allauth links a local user to a SocialAccount row holding the provider and its uid, and its OpenID Connect provider takes that uid from a setting, uid_field, whose default is sub. social-auth does the same with its UserSocialAuth table, unique on provider and uid, and its OpenID Connect backend uses sub too. A first social login with no matching row is a new person, as far as either library is concerned.
The interesting case is a first social login whose email already belongs to a local account, and both libraries refuse to merge it by default. allauth does not log the user in. It sends them to its social sign-up form, and the form refuses the address: "An account already exists with this email address. Please sign in to that account first, then connect your β¦ account." The comment in its sign-up code gives the reason, and it describes this post's attack almost word for word: the address "may not be verified, meaning, the user may be a hacker that has added your email address to their account in the hope that you fall in their trap." social-auth has a pipeline step that merges by email, associate_by_email, and leaves it out of the default pipeline, with a docstring that warns it "is not 100% secure unless you know that the providers enabled enforce email verification on their side".
So the vulnerable behaviour is something a developer has to switch on, and allauth makes switching it on deliberate. The setting is SOCIALACCOUNT_EMAIL_AUTHENTICATION, off by default, and its docstring says that turning it on "implies that an untrustworthy provider can login to any local account by fabricating social account data". Even then, allauth only matches addresses the provider marked as verified (for OpenID Connect, the email_verified claim), unless a VERIFIED_EMAIL setting tells it to treat every address from that provider as verified.
The Vulnerable Pattern and the Fix
The first pattern is the hand-written callback, the one a team writes for a provider its library does not support, or for an internal identity server. It exchanges the code and verifies the ID token correctly, and then gives the account away in the lookup. The - lines are that callback, from the lab's views_vulnerable.py; the + lines are the fix, from its views_secure.py:
tokens = idp.exchange_code(request.GET["code"], redirect_uri=CALLBACK)
claims = idp.verify_id_token(tokens["id_token"])
- user = User.objects.filter(email__iexact=claims["email"]).first()
- if user is None:
- user = User.objects.create_user(username=claims["sub"], email=claims["email"])
+ identity = Identity.objects.filter(issuer=claims["iss"], subject=claims["sub"]).first()
+ if identity is None:
+ return HttpResponseForbidden("No account is linked to this provider account.\n")
+ user = identity.user
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
The - version logs in whoever the provider says has that address. The + version only finds an account through an Identity row, keyed on iss and sub, and the only way to create one is a "connect your account" page that asks a signed-in user for their password again before it links the provider identity to their account. A first-time social login with no linked identity is refused here; in a real project it goes to sign-up, and if its email belongs to an existing account, to a page that asks the user to sign in the old way and connect from there, as allauth does. That costs some users a step. Trading the step for an email_verified check is tempting, and harder to defend than it looks: it moves the question to whether every provider you enable sets that claim truthfully for every account, today and after its next change, and that is a claim about someone else's code.
The second pattern is the same mistake in allauth, where it is two lines of settings rather than code:
SOCIALACCOUNT_PROVIDERS = {
"openid_connect": {
- "EMAIL_AUTHENTICATION": True, # log in to the local account with the same email
- "VERIFIED_EMAIL": True, # and treat every address from this provider as verified
"APPS": [...],
},
}
Either line alone is a decision you can defend for a single provider you fully trust. Together they are the - side of the first diff. The same goes for an OpenID Connect app whose uid_field was changed from sub to email or preferred_username, and for social_core.pipeline.social_auth.associate_by_email in SOCIAL_AUTH_PIPELINE. Search your settings for all four; each one should come with a comment naming the provider it trusts and why.
For a project that already finds social logins by email, the tempting migration is to record iss and sub against whatever account the email finds at each user's next social login. That writes the bug down: an attacker who signs in during the migration gets their sub bound to the victim's account for good. Switch the lookup first, then let each existing user link their provider account from the connect page, after signing in with their password or with a link sent to the address already on the account, and read the email for nothing but display.
Catching It Automatically
Testing Your Defence
The test that matters signs in as the attacker. bob_claims_alices_email() sets bob's provider account's email to alice's address on the lab provider's profile page, and sign_in_with_provider() follows the sign-in redirects the way a browser would:
def test_secure_refuses_bobs_provider_account(self):
self.bob_claims_alices_email()
bob = Client()
resp = self.sign_in_with_provider(bob, SECURE_LOGIN, "bob")
self.assertEqual(resp.status_code, 403)
self.assertEqual(bob.get(ACCOUNT).status_code, 302) # not signed in
Make the secure callback look the user up by email and it fails, because bob comes back signed in as alice. The lab's tests.py has the rest, including the stricter half of the fix (alice's own provider account, with a verified address, opens nothing until she connects it) and the proof that she can still connect hers and sign in with it. In your own project, the line to copy is the first one: give a provider account someone else's email, then sign in with it.
The settings half needs a search, not a test:
grep -rnE "EMAIL_AUTHENTICATION|VERIFIED_EMAIL|uid_field|associate_by_email" --include=*.py .
Not every hit is a problem, since "uid_field": "sub" and a setting switched off match too. The ones to read are email authentication or associate_by_email switched on, VERIFIED_EMAIL set, and a uid_field other than sub: each is a place where an email or a username can open an existing account, and each should say in a comment which provider it trusts.
Scanning It
The only redirect the scanners complained about is the one redirect in this lab that is safe. Semgrep's curated packs return two findings, both in the lab's identity provider:
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_17_oauth2/
# Ran 156 rules on 18 files: 2 findings.
open-redirect fires on the provider's authorize view, which refuses any redirect_uri outside an exact list before it redirects anywhere. The rule's definition shows why: it exempts a function only when it calls is_safe_url() or url_has_allowed_host_and_scheme(), and a membership check against a set of registered URIs is neither. The other finding, jwt-python-hardcoded-secret, is fair, since the lab's client secret is committed. The registry tier (r/python.django, r/python) adds direct-use-of-httpresponse five times, on both callbacks alike, for 7 findings from 372 rules, and Bandit reports two B105 hard-coded-password hits in the provider, one of them the dictionary key "token_type" next to "Bearer".
None of them touches line 46 of views_vulnerable.py, User.objects.filter(email__iexact=claims["email"]), or the line that replaces it. The bug is an ordinary ORM lookup, and seeing it means knowing that one claim of a correctly verified token must not identify a person. Grepping the definitions of all five Semgrep configs for the words a rule about this would most likely use (email_verified, ["email"], email__iexact, preferred_username, allauth, associate_by_email, id_token and a few more) returns 0 for every one, and Bandit's 75 checks have nothing about email or OAuth.
With nothing in any tier that flags the lookup, the lab ships a rule of its own, rules/oauth2.yaml. It is a taint rule: its sources are jwt.decode() and a verify_id_token() helper, and its sink is an account lookup keyed on the email field, so it follows the claim through a local variable as well as inline.
semgrep scan --config rules/oauth2.yaml labs/post_17_oauth2/views_vulnerable.py
# Ran 1 rule on 1 file: 1 finding.
semgrep scan --config rules/oauth2.yaml labs/post_17_oauth2/views_secure.py
# Ran 1 rule on 1 file: 0 findings.
It fires on line 46, stays silent on the (iss, sub) lookup, and the repository's CI asserts that split. Its fixture records its limits. It flags a lookup behind an email_verified check, on purpose, and it also flags harmless lookups, such as a sign-up check that only asks whether the address is taken, because it matches where the claim meets the query, not whether anyone gets logged in. It misses the same mistake keyed on preferred_username, claims read from a userinfo endpoint instead of an ID token, and the email inside a Q() or an email__in list. And it cannot see the allauth and social-auth settings at all, which is why the grep above belongs next to it.
The habit to take from this one is a single question for every "Log in withβ¦" button: which claim does the callback read to find the account? The answer that holds is an identifier the provider never reassigns, iss and sub for OpenID Connect, through a link the user made while signed in; an email or a username is a field that someone else's software lets someone else edit. Post 18 opens Series IV with weak password hashing, for the accounts that still sign in with a password.
Further Reading
- django-security-lab β
labs/post_17_oauth2/β the runnable lab: an in-lab identity provider, two login callbacks split by the claim they read, and the captured scanner output inscans/ -
django-security-lab β
rules/oauth2.yamlβ the custom Semgrep rule and, beside it, its fixture -
OpenID Connect Core 1.0 β Β§5.7 Claim Stability and Uniqueness
- django-allauth β Social account configuration
- Python Social Auth β Pipeline
- OWASP A07:2025 β Authentication Failures
- PortSwigger Web Security Academy β OAuth 2.0 authentication vulnerabilities
- MITRE ATT&CK β T1078: Valid Accounts
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress) β Chapter 10: Authentication and Authorization