Security recipes · 06
Authorization code interception
- The threat
- Capture the authorization code from the redirect leg of an OIDC flow and exchange it for tokens before (or after) the legitimate client does.
- Sentinel's counter
- PKCE with S256 only, byte-exact redirect_uri matching at both ends of the flow, hashed single-use codes, and replay that revokes the refresh family the first use minted.
- Capability
- /docs/oidc-provider/

The attack
The authorization code travels the one leg of the OIDC dance you don’t control: a
front-channel redirect through the user’s browser. That leg leaks — through a
Referer header on the landing page, a malicious mobile app registered for the same
custom URL scheme, a proxy or CDN access log, browser history synced to a compromised
account, or an open redirect that lets the attacker choose where “your” redirect
actually lands.
Classically, a captured code was a captured login: post it to the token endpoint before the victim’s client does and receive their tokens. The attack has two variants — race (redeem the stolen code first) and replay (redeem it after the legitimate client already did, catching sloppy servers that don’t enforce single use). RFC 7636 exists because the race variant was epidemic on mobile.
How Sentinel counters it
PKCE, and only its strong form. The authorize endpoint accepts
code_challenge_method=S256 and nothing else — plain is rejected outright, because a
plain challenge is the verifier and defeats the design
(code_challenge_method must be S256 (plain is not accepted)). Public clients cannot
opt out: an authorize request from a public client without a PKCE challenge is refused
(Public_client_without_pkce_is_rejected_via_redirect), and the token endpoint
double-checks — a challenge-less code for a public client is treated as store tampering
and refused. At redemption, the verifier is validated for shape and hashed against the
stored challenge; a wrong verifier is a plain invalid_grant
(Wrong_pkce_verifier_is_invalid_grant). The intercepted code is now only half a
credential — the other half never left the legitimate client’s memory.
redirect_uri is matched byte-exactly, twice. At authorize time the presented
redirect_uri must be Ordinal-equal to one of the client’s registered URIs — no
prefix matching, no wildcard subdomains, no path laxity — and a failure at this phase
is a 400 page, never a redirect (Unregistered_redirect_uri_is_a_400_page_and_never_a_redirect),
so an attacker-controlled URI can’t even be used to bounce the error. At token time the
exchange must present the same redirect_uri the code was minted for; the comparison
against the stored record is again Ordinal. Redirect-based code theft requires
bending one of these two checks, and neither bends.
Codes are hashed, single-use, and rigged to detonate. The server persists only
OidcPlumbing.HashAuthorizationCode(code) — a database leak yields no redeemable
codes. Redemption goes through IOidcStore.ConsumeCodeAsync, an atomic consume whose
Reused outcome is treated as evidence, not error: Sentinel emits oidc.code_reuse
and revokes the refresh-token family the first exchange minted, using the durable
code→grant linkage (OidcTokenGrant). Whoever redeemed the code first — victim or
thief — both end up unauthenticated, which is the correct outcome when you cannot know
which was which. The tests pin the whole behavior:
Authorization_code_is_single_use_and_reuse_emits_the_event and
Replayed_code_revokes_the_refresh_family_issued_on_first_use.
No oracle in the errors. Wrong client, expired code, and redirect_uri mismatch all
answer the identical invalid_grant — an attacker probing a stolen code cannot learn
which check tripped. And the code’s authentication is re-validated at exchange: if the
user logged out or was suspended between authorize and redemption, the code is dead
regardless of PKCE.
What your app must still do
- Register exact redirect URIs. Sentinel matches what you register; register precisely — one URI per environment, HTTPS, no “temporary” localhost entries left in production client registrations.
- Keep the verifier a secret with the lifespan of one flow. Generate a fresh high-entropy verifier per authorization request in your client and never log or persist it. A verifier reused across flows is a verifier worth stealing.
- On mobile, prefer claimed HTTPS links (App Links / Universal Links) over custom URL schemes — scheme hijacking is the one interception vector the server cannot see.
- Keep query strings out of logs on whatever host serves your redirect landing
page, and set a
Referrer-Policyso the code-bearing URL never rides a referrer. - Alert on
oidc.code_reuse. Liketoken.refresh_reuse_detected, it is a near-zero-false-positive compromise signal; route it to your SIEM via a signed webhook and treat it as an active incident.