Adopting PKCE for OAuth 2.0 Public Clients: A Decision Guide with Implementation Validation
A decision guide for adopting PKCE (RFC 7636) in OAuth 2.0 authorization code flows for public clients. Covers S256 vs. plain, implementation steps with code examples, validation checklist, and server-side enforcement requirements.
15 Nov 2025, 15:27 UTC

The Decision: PKCE Is Required for Public Clients
If you are building a single-page application (SPA), mobile app, or any client that cannot keep a secret, you must use PKCE (Proof Key for Code Exchange, RFC 7636) with the OAuth 2.0 authorization code flow. The implicit flow is deprecated for this reason: it returns tokens directly in the redirect URI fragment, exposing them to browser history, referrer headers, and script access. PKCE adds a per-request secret that proves the client exchanging the authorization code is the same one that started the flow, without requiring a client secret.
Constraints That Shape the Choice
- Client type: Public clients (no
client_secret) cannot authenticate at the token endpoint. PKCE replaces that authentication with a dynamic proof. - Authorization server support: The server must implement RFC 7636. Most modern providers (Auth0, Okta, Keycloak, IdentityServer, Azure AD, Google) do. Legacy providers may not.
- Code challenge method: RFC 7636 requires
S256(SHA-256). Theplainmethod is only a fallback when S256 is impossible; it sends the verifier equivalent in the challenge, defeating PKCE's protection. - Transport security: PKCE does not replace TLS, the
stateparameter, or strict redirect URI validation. All three remain mandatory.
Supported Options Compared
| Option | Client Type | Code Challenge Method | Security Posture | When to Use |
|---|---|---|---|---|
| Authorization Code + PKCE (S256) | Public (SPA, mobile, desktop) | S256 (required) | Strong — mitigates code interception | Default for all new public clients |
| Authorization Code + PKCE (plain) | Public (legacy only) | plain (fallback) | Weak — verifier exposed in challenge | Only when S256 cannot be implemented (rare) |
| Authorization Code + Client Secret | Confidential (backend server) | N/A (client auth used) | Strong — server-side secret | Backend-for-frontend, server-rendered apps |
| Authorization Code + PKCE + Client Secret | Confidential (defense-in-depth) | S256 | Strongest — dual proof | High-value confidential clients |
| Implicit Flow | Public (legacy) | N/A | Deprecated — token leakage risk | Never for new development |
Trade-offs
PKCE (S256) vs. Implicit Flow
The implicit flow returns the access token in the redirect URI fragment (#access_token=...). This leaks tokens to browser history, referrer headers when navigating off-site, and any JavaScript running on the page. PKCE keeps the token response out of the browser address bar entirely — the token endpoint returns it via a direct POST from the client (or backend-for-frontend). The cost is one extra round trip and the need to generate and store a code_verifier per flow.
PKCE (S256) vs. PKCE (plain)
plain sends the code_verifier itself as the code_challenge. An attacker who sees the authorization request (via referrer, logs, or network capture) gets the verifier and can exchange a stolen code. S256 sends only base64url(sha256(code_verifier)); reversing it is computationally infeasible. Use plain only if your runtime cannot compute SHA-256 — for example, a severely constrained embedded device. Modern browsers, mobile OSes, and server runtimes all support SHA-256.
Public Client vs. Confidential Client with PKCE
Confidential clients authenticate at the token endpoint with a client_secret (or private key JWT). Adding PKCE provides defense-in-depth: even if the secret is leaked, an intercepted authorization code cannot be exchanged without the per-request verifier. The overhead is minimal — the same code_verifier generation and storage logic applies.
Concrete Implementation: Generating and Using a PKCE Pair
The following steps show a minimal, standards-compliant PKCE flow for a public SPA. Adapt the code to your framework; the cryptographic requirements are non-negotiable.
1. Generate code_verifier (Client Side)
Run this in the browser or mobile app before starting the authorization request. The verifier must be:
- Cryptographically random (use
crypto.getRandomValuesor platform equivalent) - 43–128 characters
- Unreserved URL-safe characters:
A-Z a-z 0-9 - . _ ~ - Minimum 32 bytes of entropy (43 chars base64url ≈ 32 bytes)
// Browser / Node.js (Web Crypto API)
async function generateCodeVerifier() {
const array = new Uint8Array(32); // 32 bytes = 256 bits entropy
crypto.getRandomValues(array);
// base64url encode without padding
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
Where to run: Client application (browser JS, Swift, Kotlin, React Native). Permissions: Access to cryptographic random source. Risk: Using Math.random() or a low-entropy source makes the verifier guessable.
2. Compute code_challenge (S256)
async function computeCodeChallengeS256(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
Expected check: Output is a 43-character base64url string (e.g., E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM).
3. Start Authorization Request
Redirect the user to the authorization endpoint with these parameters:
https://auth.example.com/authorize?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=xyz123
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
state: Opaque, unguessable value (CSRF protection). Generate per session, store in secure cookie or session storage, validate on callback.code_challenge: The S256 hash from step 2.code_challenge_method=S256: Required; omitting it defaults toplainon some servers.
Where to run: Browser redirect (top-level navigation) or system browser tab (mobile). Risk: Using an iframe for authorization breaks PKCE and exposes credentials to the parent origin.
4. Handle Callback and Exchange Code
The authorization server redirects to redirect_uri with ?code=AUTH_CODE&state=xyz123. Validate state matches the stored value, then POST to the token endpoint:
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=YOUR_CLIENT_ID
&code_verifier=ORIGINAL_CODE_VERIFIER_FROM_STEP_1
- Public clients: do not send
client_secret. - The authorization server recomputes
base64url(sha256(code_verifier))and compares it to the storedcode_challengeusing constant-time comparison. - Mismatch →
invalid_granterror.
Where to run: Client (SPA) or backend-for-frontend (BFF). If using a BFF, the BFF stores the verifier server-side and the SPA never sees the token.
Validation Checklist
After implementation, verify each point:
- Correct verifier generation: Inspect the
code_verifierlength (43–128 chars) and character set (unreserved only). - S256 challenge in authorize request: Check network tab —
code_challenge_method=S256and a 43-charcode_challengeare present. - Token exchange succeeds: Valid
code_verifieryields access token (and ID token if OIDC). - Wrong verifier fails: Submit an altered verifier; expect
invalid_grant(notinvalid_request). - Replay fails: Reuse the same authorization code with the correct verifier; expect
invalid_grant(codes must be single-use). - State validation: Callback with mismatched
stateis rejected before token exchange.
Server-Side Validation (Authorization Server Operators)
If you operate the authorization server, ensure:
- Store
code_challengeandcode_challenge_methodbound to the authorization code. - At token endpoint, recompute hash from presented
code_verifierusing the stored method. - Compare with constant-time comparison (e.g.,
MessageDigest.isEqualin Java,hmac.compare_digestin Python,crypto.timingSafeEqualin Node). - Reject if method is
plainand your policy requires S256. - Enforce PKCE for all public clients (client authentication method =
none).
Limitations and What PKCE Does Not Solve
- TLS is still required: PKCE protects the code, not the channel. Without TLS, an attacker can modify the authorization request to strip or replace
code_challenge. - State parameter is still required: PKCE does not prevent CSRF on the authorization request. The
stateparameter binds the flow to the user session. - Redirect URI validation is still required: The server must match the redirect URI exactly (including scheme, host, path) against pre-registered values.
- Token endpoint attacks: If a confidential client's
client_secretis compromised, PKCE does not prevent token exchange — the attacker has both the secret and can generate a valid verifier. Rotate secrets and use private key JWT (client_assertion) for high-value clients. - Legacy IdPs: Some older providers lack PKCE support. Options: migrate, use a BFF with client secret, or accept residual risk with short-lived codes and strict redirect URI matching.
Practical Verification with a Test Suite
Run an OAuth 2.0 security test suite (for example OAuthSec, or the checks from the OWASP OAuth 2.0 Security Cheat Sheet) against your authorization server to confirm PKCE enforcement. A typical manual negative test:
# Exchange with a deliberately wrong verifier; expect invalid_grant
curl -X POST https://auth.example.com/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=AUTH_CODE_FROM_CALLBACK' \
-d 'redirect_uri=https://app.example.com/callback' \
-d 'client_id=YOUR_CLIENT_ID' \
-d 'code_verifier=WRONG_VERIFIER_VALUE'
Where to run: Any machine with network access to the token endpoint. Expected check: HTTP 400 with {"error":"invalid_grant"}. If the exchange succeeds, the server is not validating PKCE and must be fixed before launch.
Library Support Quick Reference
| Library / Framework | PKCE Support | Configuration Hint |
|---|---|---|
| Spring Security (OAuth 2.0 Client) | Automatic with authorization code flow | Set client authentication method to none for public clients |
| Duende IdentityServer (ASP.NET) | Built-in; enable per client | RequirePkce = true on Client config |
| Keycloak | Enforced for public clients by default | Client Settings → Access Type: public |
| Auth0 SPA SDK | Automatic (generates verifier, stores in memory) | No extra config; uses authorization code with PKCE |
| AppAuth (iOS/Android/JS) | Full PKCE support (S256 default) | Confirm S256 is the challenge method in config |
Summary
PKCE with S256 is the standard, required mechanism for public clients using the OAuth 2.0 authorization code flow. It replaces the deprecated implicit flow by adding a per-request cryptographic proof that the client exchanging the code initiated the flow. Implement it by generating a 32+ byte random code_verifier, sending its SHA-256 base64url hash as code_challenge with code_challenge_method=S256, and presenting the original verifier at the token endpoint. Validate the implementation with negative tests (wrong verifier, replayed code). Remember: PKCE complements — not replaces — TLS, state, and strict redirect URI validation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.