OAuth PKCE for Public Clients: Binding the Authorization Code to the Request That Started It
Public clients cannot keep secrets. PKCE replaces the static client secret with a per-request proof, making intercepted authorization codes useless to attackers. Here is how to implement it correctly.
20 Oct 2025, 02:20 UTC

The exposed authorization code
When a single-page app or mobile app signs a user in, the authorization server redirects the user agent back to the app with an authorization code in the URL. That code is the key to the token endpoint. A confidential client—say, a backend service—can guard it with a client secret. A public client cannot. Code running in a browser or shipped in a mobile binary is inspectable, so any static secret embedded in it is effectively public.
The result is a narrow but dangerous window: anyone who observes the redirect—through a malicious app on the same device, a proxy log, or browser history—can replay the code and mint tokens. The useful takeaway is that PKCE (Proof Key for Code Exchange, RFC 7636) closes this window by requiring the client to prove it initiated the specific request. It replaces the static secret with a per-request secret that never travels with the code.
How PKCE binds the code to the client instance
PKCE adds two parameters to the standard authorization code flow. Before redirecting the user to the authorization server, the client generates a high-entropy random string called the code_verifier (43–128 characters, encoded with base64url, a URL-safe Base64 variant). High entropy means the string is unpredictable and drawn from a space large enough that guessing it is infeasible. The client then computes the code_challenge as the Base64URL-encoded SHA-256 hash of that verifier and sends the challenge in the authorization request.
The authorization server stores the challenge alongside the pending authorization code. When the client later presents the code at the token endpoint, it must also send the original code_verifier. The server recomputes the hash and compares it to the stored challenge. If they do not match, the exchange fails with invalid_grant. Because only the client instance that created the verifier knows the original string, an intercepted code is useless to anyone else.
Two rules matter here. First, always use code_challenge_method=S256. The plain method sends the verifier itself as the challenge, which provides no protection and is deprecated. Second, generate a new verifier for every authorization request. Reusing a verifier breaks the per-request binding and enables replay.
A worked exchange
The following snippets illustrate the shape of a PKCE-protected flow. Values are representative of the protocol structure, not captured from a live server.
1. Client prepares the proof. The app generates a cryptographically random verifier and derives the challenge:
code_verifier = random_base64url(32_bytes) // e.g., 43 chars
code_challenge = base64url(sha256(code_verifier))
2. Authorization request. The client redirects the user to the authorization endpoint, including the challenge and a CSRF token:
GET /authorize?
response_type=code&
client_id=public-spa-client&
redirect_uri=https://app.example.com/callback&
scope=openid%20profile&
state=xyz123&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
3. Redirect back. After the user authenticates, the authorization server returns the code to the registered redirect URI:
GET /callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz123
4. Token request. The client exchanges the code, proving possession of the verifier:
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https://app.example.com/callback&
client_id=public-spa-client&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
If the verifier is missing, malformed, or does not hash to the stored challenge, the server must reject the request. A practical negative test is to alter one character of the verifier and confirm the token endpoint returns invalid_grant rather than issuing tokens.
What PKCE does not fix
PKCE secures the token exchange, not the entire flow. It does not replace exact redirect URI matching. If an attacker controls a registered redirect URI—because the registration used a loose pattern or a wildcard—they may receive the authorization code. PKCE still prevents them from redeeming it without the verifier, but strict redirect URI validation remains a separate, mandatory control.
It also does not replace the state parameter. PKCE binds the code to the client instance; state binds the response to the user's browser session and prevents cross-site request forgery. You need both.
Additional limitations worth planning around:
- URL logging. The
code_challengeappears in the authorization request URL. Proxies, browser history, or referral headers may log it. Use TLS everywhere and avoid logging full authorization URLs. - Native app redirects. Custom URI schemes can be hijacked on some mobile platforms. Prefer claimed HTTPS redirect URIs (Android App Links, iOS Universal Links) where the platform supports them.
- Legacy identity providers. Some authorization servers deployed before 2019 may lack PKCE support or contain verification bugs. Confirm enforcement before relying on it.
Actionable checklist
- Configure the authorization server to require PKCE for public clients and reject
/authorizerequests that omitcode_challenge. - Generate a fresh, high-entropy
code_verifierper authorization request and compute the challenge with SHA-256. - Send
code_challenge_method=S256and a uniquestatevalue in every authorization request. - Inspect your network traffic: verify the
/authorizerequest carries the challenge and the/tokenrequest carries the matching verifier. - Run a negative test with a corrupted verifier and confirm the server returns
invalid_grant. - For mobile apps, migrate away from custom URI schemes to claimed HTTPS redirects where possible.
PKCE is not optional hardening; for any client that cannot keep a secret, it is the baseline that makes the authorization code flow safe. The OAuth 2.1 draft makes PKCE mandatory for all authorization code flows, not just public clients, reflecting its status as a foundational control. Treat it as a prerequisite alongside exact redirect URI matching and state validation, not a substitute for them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.