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.

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-Policy so the code-bearing URL never rides a referrer.
  • Alert on oidc.code_reuse. Like token.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.