---
title: "The Flow Is Standard. The Bypass Is in the Half You Skipped."
description: "OAuth 2.0 and OIDC vulnerabilities are almost never in the specification. They are in the parts implementations leave out — state, PKCE, exact redirect matching, audience validation — and the flow runs perfectly while each omission sits there waiting."
date: 2025-05-29
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/oauth-flow-abuse/
---

## 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](/posts/path-normalisation-auth-bypass/):
the provider authorises one string, the browser fetches another, and the bug is the gap
between two parsers that both behaved as documented.

<figure class="diagram">
<svg viewBox="0 0 700 244" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The authorization code flow, with the three checks everyone implements on the left and the three that get skipped on the right: exact redirect matching, audience validation, and state.">
<defs>
<marker id="oa-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker>
<marker id="oa-c" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker>
</defs>
<text x="0" y="18" class="dg-muted">IMPLEMENTED — THE FLOW LOOKS COMPLETE</text>
<rect x="0" y="32" width="196" height="52" rx="8" class="dg-box" />
<text x="14" y="52" class="dg-label">redirect_uri set</text>
<text x="14" y="70" class="dg-accent">REGISTERED AND MATCHED</text>
<rect x="204" y="32" width="196" height="52" rx="8" class="dg-box" />
<text x="218" y="52" class="dg-label">code exchanged</text>
<text x="218" y="70" class="dg-accent">TOKEN RETURNED</text>
<rect x="408" y="32" width="196" height="52" rx="8" class="dg-box" />
<text x="422" y="52" class="dg-label">user logged in</text>
<text x="422" y="70" class="dg-accent">SESSION ISSUED</text>
<line x1="0" y1="112" x2="700" y2="112" class="dg-line dg-dash" />
<text x="0" y="138" class="dg-crit">SKIPPED — WHERE THE FINDING ACTUALLY LIVES</text>
<rect x="0" y="152" width="196" height="60" rx="8" class="dg-box-accent" />
<text x="14" y="172" class="dg-label">exact match</text>
<text x="14" y="190" class="dg-crit">"CONTAINS" ≠ EQUALS</text>
<text x="14" y="206" class="dg-muted">code leaks to attacker</text>
<rect x="204" y="152" width="196" height="60" rx="8" class="dg-box-accent" />
<text x="218" y="172" class="dg-label">audience</text>
<text x="218" y="190" class="dg-crit">TOKEN NOT AUD-CHECKED</text>
<text x="218" y="206" class="dg-muted dg-compact">wrong-client token accepted</text>
<rect x="408" y="152" width="196" height="60" rx="8" class="dg-box-accent" />
<text x="422" y="172" class="dg-label">state</text>
<text x="422" y="190" class="dg-crit">NEVER GENERATED</text>
<text x="422" y="206" class="dg-muted dg-compact">victim bound to attacker session</text>
</svg>
<figcaption>The three checks that make the flow look finished are all present; the three that make it correct are absent. A review that only confirms the left column is not a review.</figcaption>
</figure>

## 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](/posts/the-template-nobody-owned/)
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.