Proof Key for Code Exchange is often described as a mobile-app control. The current OAuth security guidance treats it as a baseline for browser-based public clients and a recommended defense for confidential clients as well. The reason is straightforward: an authorization code is a bearer artifact until the token endpoint binds it to the client that started the request. PKCE supplies that binding with a transaction-specific challenge and verifier.

The binding fails when each value is validated in isolation. A server might accept a verifier when the original authorization request contained no challenge. It might compare the verifier to a challenge from a different user session. It might accept the plain challenge method even though the client registered only S256 support. These implementations can pass a basic happy-path test while leaving room for code injection or request substitution.

What the protocol requires

RFC 7636 defines a client-generated code_verifier, a transformed code_challenge, and a code_challenge_method. The verifier is a high-entropy string. The S256 method sends a SHA-256 hash of the verifier in the authorization request and the original verifier in the token request. The authorization server stores the challenge with the transaction and checks it when the code is redeemed.

RFC 9700 adds operational requirements that matter to implementers. Public clients must prevent authorization code injection and misuse by using PKCE. Authorization servers must support it. A token request containing code_verifier must be accepted only when the authorization request contained code_challenge. The challenge must also be transaction-specific and bound to the client and user agent where the flow began. S256 is the only challenge method that does not expose the verifier in the authorization request.

That last point is not a preference for cryptographic elegance. The plain method puts the verifier in the front channel, which weakens the protection against an attacker who can read the authorization request. A configuration that offers plain as a fallback may therefore undo the protection even when well-behaved clients choose S256.

Model the binding as state

The authorization server should create a short-lived transaction record when the authorization request is validated. The record needs the client, redirect URI, user interaction context, challenge method, challenge value, and an expiry. The code should reference that transaction. At redemption, the server should confirm that the code is unused, unexpired, issued to the authenticated client, and still linked to the original challenge.

Do not compare the verifier to a challenge stored only in a client cookie. The record belongs on the server and must not be selectable by an attacker-controlled session identifier. If the authorization server supports multiple front-channel applications or regional issuers, use a transaction ID that is opaque and cannot be swapped across clients.

Return a standards-aligned error for a mismatch and make the failure audit-friendly. Log the client identifier, issuer, transaction age, method, and outcome. Do not log the verifier or the full authorization code. A mismatch should invalidate the code when the implementation can do so safely, because repeated redemption attempts can be a sign of code theft or a client bug.

Test the downgrade paths

Exercise more than the normal S256 flow. Start an authorization request without a challenge, then send a verifier to the token endpoint. Start with a challenge and omit the verifier. Send a plain challenge where S256 is required. Reuse a code after a failed attempt. Swap codes between two browser sessions for the same client. Try a code issued to one client with a verifier from another transaction.

The expected result is consistent rejection, not a generic success from an earlier session or a fallback to another tenant. Repeat the tests after proxy, identity-provider, and load-balancer changes. A gateway that strips an empty parameter may turn a malformed request into a valid one, while a provider that replays cached form data may mask a session-binding error.

Monitor without collecting secrets

Track challenge methods in use, PKCE failures by client, token endpoint error codes, and the age of rejected codes. Alert on a sudden move from S256 to plain, a spike in verifier mismatches, or one client producing failures across many users. Correlate the signal with deploy times and client releases before calling it an attack.

Client code also needs a review. Generate a new verifier for every authorization attempt. Keep it out of URLs, logs, analytics, and crash reports. Do not reuse a verifier after a timeout or a failed callback. When the callback state is invalid, stop instead of attempting token exchange with whatever result was returned.

PKCE does not make a stolen access token harmless, and it does not replace redirect URI validation or client authentication. It protects a specific transition in the authorization code flow. The control works when the challenge, code, client, and verifier remain one transaction from start to finish.

References