OAuth security review in 60 seconds
An OAuth review almost always starts with the same finding-shaped absence: nobody can point to where the flow is broken, because the flow is not broken. The specification is sound. The provider is configured. The exchange completes. And an attacker can still take over an account, because the vulnerability is not in any one step — it is in a step that was never built.
OAuth is a delegation protocol, and most of its failures are failures to delegate only what was intended. The bug is in the half of the spec the implementer decided not to need.
The three things everyone checks, and the three they skip
Every OAuth assessment verifies the same three properties, because they are the ones the documentation warns about. And every OAuth finding comes from one of the three properties that sit next to them, unimplemented, because nobody writes a ticket for a value that was never generated.
Checked: the redirect URI. Skipped: whether it is matched exactly. Checked: that a token comes back. Skipped: that the token is validated for the right audience. Checked: that the user is logged in. Skipped: that the login was this user, not an attacker’s session a victim was dropped into.
The pattern is the same in all three: the part that makes the flow look complete is present, and the part that makes it correct is not.
The redirect is the whole game, and “contains” is not “equals”
The authorization code is handed to whatever URI is registered, which makes the redirect validation the single load-bearing check in the entire flow. The bug is not that it is missing. It is that it is matched loosely.
A client registered with https://app.example.com/callback will, in a shocking number of
implementations, accept any of these:
https://app.example.com/callback.attacker.com
https://app.example.com/callback/../evil
https://app.example.com.attacker.com/callback
https://app.example.com/callback%40attacker.com
A substring match, a missing trailing-slash check, or a URL parser that does not agree with the browser about what the host is — and the code goes to the attacker’s endpoint. This is the same divergence as in the path normalisation note: the provider authorises one string, the browser fetches another, and the bug is the gap between two parsers that both behaved as documented.
State is not a nicety. It is the difference between login and login-CSRF
The state parameter is the field most implementations leave out, because on a happy-path
demo the flow works without it. Its job is to bind the browser that started the login to the
one that completes it.
Without state, an attacker starts a login with their own account, gets a valid
authorization code for themselves, and then sends the victim a link that completes the
login with the attacker’s code. The victim is now authenticated — but as the attacker. If
the application lets them attach anything to that session — a credit card, an API key, a
shipping address — the attacker reads it later by logging into the same account.
Nothing in the flow was broken. The login succeeded, exactly as designed, with a credential the application was never told to reject because the parameter that would have told it to was never implemented.
PKCE is for the public clients nobody reviews
Authorization Code + PKCE exists because the implicit flow leaks tokens into fragments and
logs, and a public client has no client secret to prove the code is its own. The proof moves
into the code_verifier/code_challenge pair instead.
The finding shape is predictable: a mobile or SPA client that was migrated from the implicit flow to the code flow, but without PKCE, on the theory that the code flow alone was the improvement. It was not. A code flow on a public client without PKCE has the same code-interception exposure the implicit flow had, now dressed as something modern.
The token confusion nobody checks for
The final skipped step is the subtlest: which token you received, and whether it is valid for this application.
An ID token proves who the user is. An access token proves what the user may do. They are different tokens, for different audiences, and they must never be interchangeable. The two failures that follow from conflating them:
Accepting an access token as proof of identity. If the client treats any token it can obtain as an authenticated session, an attacker who can get the service to mint them a token for a different audience has just authenticated as themselves — or as whoever that token was issued to.
Never validating aud. A token minted for client A is accepted by client B because B
only checks the signature, not the audience claim. The signature is valid. The token is
real. It just was not issued for B, and accepting it anyway is how one client’s compromise
becomes every client’s.
The fix is one check, and it is the check that gets skipped precisely because the signature validation passed.
Testing it as an assembly, not a flow
The productive test is not “does the login work”. It is walking the flow once with a specific eye to the omitted half.
# 1. Does the redirect match exactly, or does a suffix/parser trick change the host?
GET /authorize?response_type=code&client_id=...&redirect_uri=https://app.example.com/callback.attacker.com
# 2. Is state required? Complete a login without it and see whether the flow proceeds.
GET /authorize?response_type=code&client_id=...&redirect_uri=... # no state
# 3. Is the token's audience validated, or only its signature?
# Replay a token minted for a different client_id against this client's endpoint.
Each of those is a single request, and each answers a question the demo path never poses.
Evidence matrix
| Signal | What it proves | Negative control | Defender verification |
|---|---|---|---|
| A non-exact redirect variant receives an authorization code | Redirect validation and browser URL interpretation disagree | Use an unregistered host with an exact parser and expect a hard rejection | Unit-test scheme, host, port, path, and normalization against the registered URI |
Login completes with missing, reused, or attacker-supplied state | The callback is not bound to the browser session that initiated it | Start two sessions and confirm each rejects the other’s state | Log state generation, one-time consumption, expiry, and session binding without logging the value |
| Public client exchanges a stolen code without the original verifier | PKCE is absent or not enforced at the token endpoint | Repeat with a wrong verifier and require rejection | Verify S256 challenge enforcement for every public client registration |
| Token for another client/audience creates a session | Signature validation exists but audience and token purpose checks do not | Replay a same-issuer token with a wrong aud and expect denial | Validate issuer, audience, token type, nonce, and authorized party at the consuming service |
The pattern I keep seeing
OAuth gets bolted on at the identity layer, by a team that read the OAuth 2.0 RFC once, and the parts they skipped were skipped because they were optional in the happy path. Then the application grows, the login keeps working, and the omission is inherited by every consumer of the login afterwards — because nothing ever fails, so nothing ever sends anyone back to look.
It is the same residue the certificate template note describes: a system assembled correctly at the time, sitting unreviewed because no single team owns the whole of it. The identity team owns the provider, the application team owns the client, and neither owns the gap between them.
What to hand the defenders
Name the specific omitted check, not “OAuth is weak”. “The redirect URI is matched with a substring comparison, allowing code interception via an open redirect” is a finding. “Review your OAuth implementation” is a project, and it will not get scheduled.
State as a hard requirement. It is a single value and a single comparison, and it closes the login-CSRF class entirely. There is no legitimate reason for an interactive login flow to run without it.
PKCE for every public client, and audience validation for every token. Both are one-liners in the library most teams are already using, and both are the difference between a standard flow and a standard flow that can be aimed at the wrong person.
The flow was never the problem. The problem is that “standard” describes the protocol, not the implementation.
How current is this note?
The latest source-review, content-update, or publication date is shown.
The author completed a technical review. This does not, by itself, claim lab reproduction.
