The premise. OAuth 2.0 and OpenID Connect (OIDC) are delegation protocols, not security products. The spec gives you a long list of parameters and tells you which combinations are dangerous. Implementations do not always read that part. The result is that, after years of pentesting OAuth flows, we still find the same five classes of bug in production — and they all lead to the same place: full account takeover with one click, sometimes zero.
This is the longer-form version of what we walk our clients through every week. We are going to look at how these attacks work end-to-end, with real HTTP exchanges, real CVEs, and real defenses that we have shipped into production code.
The shape of an OAuth flow (one minute)
For the non-cryptographers reading this, here is the entire flow in five lines:
- User clicks Sign in with X on your site (the relying party).
- Your site sends them to X's authorization server with a callback URL and a random
statevalue. - User logs in at X. X redirects back to your callback URL with a one-time
codeand the samestateechoed back. - Your backend exchanges the
codefor anaccess_token(and, for OIDC, anid_token). - You now have a token that proves the user is who X says they are. You log them in.
Every bug we will discuss is a violation of an assumption baked into one of those five steps. The assumptions feel obvious. They are routinely violated.
1. The state parameter that was not validated
The state parameter exists to bind the start of an OAuth flow (step 2) to its completion (step 3). It is a CSRF token for the authorization round-trip. Without it, an attacker can do something that looks impossible: silently link the victim's session to the attacker's account.
Concretely, the attack:
1. Attacker starts a "Sign in with Google" flow on victim.com using
their own Google account. They capture the callback URL but do
NOT visit it:
https://victim.com/auth/callback?code=ATTACKER_CODE&state=abc
2. Attacker tricks the victim into clicking that URL
(phishing email, hidden iframe, etc.).
3. The victim's browser carries the victim's victim.com session
cookies AND the attacker's authorization code to the callback.
4. victim.com exchanges the code for a token. It does NOT bind
the code to the original initiator. The token belongs to the
attacker's Google account.
5. victim.com associates the attacker's Google identity with the
victim's account. The attacker now has Google-login access to
the victim's account.
The fix is one cookie and a comparison: pick a random state at step 2, store it server-side bound to the session, and refuse the callback if it does not match.
What we still find in production:
- Apps that send the
statebut never check it on return. (Constant value, missing comparison, comparison done client-side.) - Apps that use a value that is not bound to the session (e.g., a static per-deploy secret).
- Mobile apps that use a deep-link callback without verifying the originator at all.
Each of these is one HTTP exchange away from account takeover for any user who clicks a link.
2. redirect_uri validation that is "close enough"
The OAuth spec says the authorization server MUST validate the redirect_uri against a pre-registered allowlist. The spec does not insist on exact match. Many implementations have, at various points, done partial matching. The consequences are spectacular.
Classic patterns we have exploited:
Prefix matching
# Registered:
https://app.example.com/cb
# Accepted (because it starts with the registered prefix):
https://app.example.com/cb.attacker.com
https://app.example.com/cb/redirect?url=//attacker.com
https://app.example.com/cb@attacker.com
The third one is the spiciest: userinfo@host URL syntax is valid, browsers honor it, and the prefix check is fooled. The authorization code goes to attacker.com.
Subdomain wildcards
# Registered:
https://*.example.com/cb
# Attacker registers a subdomain (free providers + DNS, or a takeover):
https://evil.example.com/cb
Subdomain takeover (see our separate post on the topic) turns this from "needs to compromise example.com" into a self-service attack.
Open redirector chain
The authorization server validates that the redirect URL is a registered domain, but the relying party has an open redirect endpoint. The chain:
https://app.example.com/cb?next=https://attacker.com/steal
↓ relying party's /cb honors ?next= and 302s the browser
↓ the browser arrives at attacker.com WITH the authorization code in the URL or Referer
↓ attacker has the code, exchanges it, owns the account
Real public history: Sign in with Apple in May 2020 (Bhavuk Jain, $100,000 bounty) — Apple's server issued JWTs for any email address an attacker requested in the JWT request body, with no validation that the requester owned the address. GitHub's OAuth integration with Cloudflare (2021) chained an open redirect into authorization code theft. We routinely find this pattern fresh, in custom-built relying parties, every month.
The only safe validation is exact-string match against registered URIs. The IETF's own OAuth 2.0 Security Best Current Practice document (RFC 9700, 2025) makes this explicit. If your authorization server does anything less, treat it as a vulnerability.
3. The "mix-up attack" — when relying parties trust the wrong issuer
This one is more subtle and was first publicly described by Daniel Fett, Ralf Küsters, and Guido Schmitz in 2014. It applies to relying parties that support more than one identity provider.
The attack works because OAuth's response from the authorization server tells the relying party "here is a code" without telling it "this code is from this issuer." If your code can call multiple providers and you do not track which provider a particular flow started against, an attacker who controls one of those providers (their own malicious IdP that you support) can hand you a code that you redeem against a different, trusted IdP.
Concretely:
1. Attacker initiates a flow on relying-party.com choosing "Sign in
with EvilCorp" (a malicious IdP the RP trusts for some users).
2. EvilCorp returns a redirect to relying-party.com's callback with
a "code" parameter that is actually a valid Google authorization
code for the attacker's Google account.
3. The relying party reaches its callback handler. The handler
determines "which IdP did this flow start with?" by looking at
request state — which the attacker can manipulate.
4. The RP exchanges the code at Google's token endpoint (not
EvilCorp's). Google issues a valid token for the attacker's
Google identity.
5. RP binds attacker's Google identity to the victim's RP account.
The fix codified in OIDC Core spec: iss parameter on the authorization response, and the RP must verify iss matches the issuer of the flow it started. Older RP libraries did not implement this. Many still do not.
4. PKCE absence on public clients
If your client is a single-page app, a mobile app, or any kind of public client (one that cannot keep a secret), the client secret is — almost by definition — not secret. An attacker who can intercept the authorization code (through a malicious app, a same-origin XSS, a logged URL) can redeem it themselves.
PKCE (RFC 7636) fixes this. The client generates a high-entropy code_verifier, sends a SHA-256 hash of it (code_challenge) to the authorization server in step 2, and presents the original code_verifier when redeeming the code in step 4. Even if the attacker steals the code, they cannot redeem it without the verifier.
What we find:
- Mobile and SPA apps that do not use PKCE at all.
- Apps that use PKCE with
plainchallenge method (S256 should be mandatory). - Server-side validation that does not actually compare the verifier against the stored challenge (silent acceptance).
RFC 9700 makes PKCE mandatory for all clients now, including confidential ones. The number of production apps that have caught up: not high.
5. id_token verification that does not verify
OpenID Connect's id_token is a JWT signed by the identity provider. Your relying party verifies the signature against the IdP's public keys (typically fetched from a /.well-known/jwks.json endpoint).
This is JWT validation, and JWT validation is its own minefield (we have a whole separate post on JWT bypasses — read it after this one). For OIDC specifically, the bugs we find:
- Algorithm trust: the RP trusts the
algfield of the token instead of pinning it. An attacker who controls the algorithm (e.g., viaalg: noneor HS256/RS256 confusion) forges tokens for any user. - Missing
audcheck: the relying party verifies the signature but does not check that the token'saud(audience) claim matches its own client ID. An id_token issued by Google for a totally unrelated application of the same IdP is accepted as authentication. - Missing
isscheck: as above, but for issuer. A token from any IdP using the same algorithm and key family will be accepted. - Unverified email trust: the token includes an
emailclaim withemail_verified: false, and the RP uses the email to find an existing account. An attacker creates an IdP account using the victim's email (which IdPs allow without verification) and is logged in as the victim.
The fourth one is what we exploited on a major SaaS provider's pentest in 2023. Half a day from finding the bug to full ATO on any user account.
How we test this, and what your team should ask
On every engagement that includes a social-login flow, we run through this checklist explicitly:
- Tamper with
state— missing, replayed, constant, cross-account. - Tamper with
redirect_uri— appended path, subdomain, query parameter, userinfo syntax. - Tamper with
response_type— code, token, code id_token (hybrid). - Cross-account binding via unverified email from a malicious IdP.
- Scope downgrade and upgrade after consent.
- PKCE absence; PKCE with plain challenge; PKCE without verification.
- id_token: forced algorithm, missing audience, unsigned variant, wrong issuer.
- For mix-up: support for multiple IdPs and whether
issis verified.
If your last assessment did not name and exercise at least these items, it did not test OAuth. The "we use a library" answer is not sufficient — the bugs above are library-level, configuration-level, and integration-level all at once. They live in the seams.
A closing point about cost
The bugs in this article are not theoretical. Sign in with Apple was $100K. The Microsoft Outlook OAuth tenant confusion of 2023 cost three months of customer trust. Most of the OAuth bugs you read about on Twitter were found by people who spent an afternoon on the implementation. The economics of finding them are very favorable to attackers.
The economics of fixing them are also favorable to you — most of the patches are 1-10 lines of code in the relying party. The hard part is knowing which ones to write. That is what this kind of assessment is for.