
OAuth 2.0 Authorization Code Flow with PKCE: A Complete Guide
A complete guide to implementing the OAuth 2.0 Authorization Code Flow with PKCE for secure SPA and mobile authentication.
The standard OAuth 2.0 Authorization Code Flow was designed for confidential clients—applications that can safely store a client_secret on a backend server. When this flow is applied to Single Page Applications (SPAs) or mobile apps, known as "public clients," a critical mechanism failure occurs. These applications cannot hide a secret; they must execute entirely within the user's browser or device environment. If a standard Authorization Code Flow is used here, an attacker can intercept the authorization code as it travels from the Authorization Server back to the client, exchange it for an access token, and impersonate the user. The mechanism that fixes this is Proof Key for Code Exchange (PKCE), defined in RFC 7636. PKCE does not rely on a shared secret; instead, it relies on a cryptographic binding between a temporary challenge and a secret verifier generated locally by the client.
The Interception Vector
To understand why PKCE is necessary, we must look at the mechanism of the standard flow failure. Imagine a scenario where Alice uses a public web application called "App" to log into a service provider "Service".
- Alice initiates login. App redirects her to Service's authorization endpoint with a
redirect_uripointing to a specific URL, sayhttps://app.example.com/callback. - Service authenticates Alice and redirects her back to
https://app.example.com/callbackwith an authorization code attached to the query string. - App receives the code and exchanges it for a token.
The vulnerability lies in step 2 and 3. If an attacker, Bob, is on the same network or has compromised a DNS entry, he can intercept the redirect from Service. Because the redirect_uri is just a URL, Bob can trick the Authorization Server into sending the code to a URL he controls, such as https://bob.evil.com/steal. Alternatively, if the code is visible in the browser history or network logs, Bob can capture it. Once Bob has the code, he sends a POST request to the Token Endpoint with his own client_id (which is public anyway) and the stolen code. The Authorization Server, seeing a valid code for a valid client_id, hands over the access token. Bob now has access to Alice's data.
This attack works because the Authorization Server assumes the code is only valid for the specific client that requested it. In the standard flow, this assumption is protected by the client_secret. Since public clients lack this secret, the code becomes a "bearer token" for the duration of the exchange, making it a high-value target.
The PKCE Mechanism
PKCE solves this by introducing a two-part cryptographic handshake that ensures the client requesting the token is the same client that received the authorization code. This is achieved through a code_verifier and a code_challenge.
The mechanism operates on a "hash-and-match" principle. Before the user even logs in, the client generates a high-entropy random string called the code_verifier. This string is kept secret and stored in the client's memory (e.g., a variable in JavaScript or a local secure storage).
Next, the client creates a code_challenge by hashing the code_verifier. There are two methods for this: plain (sending the verifier directly, which is insecure) and S256 (SHA-256 hash). The industry standard is S256. The client takes the code_verifier, runs it through SHA-256, and then Base64url-encodes the result. This resulting string is the code_challenge.
During the initial authorization request, the client sends this code_challenge to the Authorization Server along with the code_challenge_method=S256 parameter. The Authorization Server stores this challenge but does not know the original verifier yet.
Later, when the Authorization Server redirects the user back with the authorization code, the client must immediately exchange that code for a token. In this second request (the token exchange), the client sends the original code_verifier (the unhashed string).
The Authorization Server then performs the same operation: it takes the received code_verifier, hashes it with SHA-256, and compares the result to the code_challenge it stored earlier. If the hashes match, the server knows that the entity requesting the token possesses the original code_verifier that was generated at the start of the flow. An attacker who intercepted the authorization code would not have the code_verifier because it never left the user's device and was never transmitted to the server until the final step. Without the verifier, the attacker cannot generate the correct hash, and the token exchange fails.
The Complete Sequence
Let's trace the exact data flow with named artifacts. We will use the following actors: User (Alice), PublicClient (the SPA), AuthServer (the provider), and Attacker (Bob).
Step 1: Challenge Generation
The PublicClient generates a code_verifier. This must be a high-entropy string, typically 43 to 128 characters long.
// Pseudocode for verifier generation
const codeVerifier = generateRandomString(64);
// Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"The client then computes the code_challenge:
// SHA-256 hash of the verifier, then Base64url encoded
const codeChallenge = base64urlEncode(sha256(codeVerifier));Step 2: Authorization Request
The PublicClient redirects the User's browser to the AuthServer's authorization endpoint. The request includes the code_challenge but not the code_verifier.
GET /authorize?
client_id=app_client_id&
redirect_uri=https://app.example.com/callback&
response_type=code&
scope=read+write&
code_challenge=base64url_encoded_hash&
code_challenge_method=S256&
state=xyz123If Bob tries to intercept this request, he sees the code_challenge but cannot reverse the SHA-256 hash to get the code_verifier.
Step 3: Authorization Response
After the User authenticates, the AuthServer redirects the browser to the redirect_uri with an authorization_code.
GET /callback?
code=s1925x925&
state=xyz123Note that the code is now in the URL. If Bob intercepts this, he has the code, but he still lacks the code_verifier.
Step 4: Token Exchange
The PublicClient detects the code and immediately sends a POST request to the Token Endpoint. It includes the authorization_code and the original code_verifier.
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=s1925x925&
redirect_uri=https://app.example.com/callback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkStep 5: Verification
The AuthServer receives the request. It retrieves the code_challenge associated with the authorization_code (which it stored in Step 2). It hashes the incoming code_verifier using SHA-256.
- If
SHA256(incoming_verifier) == stored_challenge, the server issues the access token. - If they do not match, the server rejects the request with
invalid_grant.
If Bob attempts to replay this flow, he sends the stolen code but cannot provide the correct code_verifier. The AuthServer calculates the hash of whatever code_verifier Bob sends (likely nothing or a random string), compares it to the stored challenge, finds a mismatch, and denies the token.
Implementation Constraints and Tradeoffs
Implementing PKCE requires strict adherence to the generation of the code_verifier and the management of the state parameter to prevent Cross-Site Request Forgery (CSRF). The state parameter is a random string sent in Step 2 and returned in Step 3; the client must verify it matches before proceeding to Step 4. While PKCE prevents code interception, it does not inherently protect against CSRF, so both mechanisms must be used together.
A common tradeoff involves the choice of code_challenge_method. The S256 method is mandatory for new implementations and is significantly more secure than the legacy plain method. The plain method transmits the code_verifier in the clear as the code_challenge, effectively rendering PKCE useless against code interception attacks because the verifier is exposed if the redirect URI is compromised. The S256 method ensures that even if the code_challenge is logged or intercepted, the code_verifier remains safe.
From a performance perspective, the SHA-256 hashing required for S256 is negligible on modern devices. The computational cost is standard for web clients and does not impact user experience. However, developers must ensure the code_verifier is stored securely in memory and cleared immediately after the token exchange. In SPAs, using localStorage or sessionStorage to persist the verifier across redirects is acceptable, but the verifier should be deleted as soon as the token is received to minimize the window of exposure.
It is worth noting that the OAuth 2.1 specification (draft-ietf-oauth-v2-1) recommends PKCE for all public clients, effectively treating the client_secret as obsolete for these application types. This shift acknowledges that the threat model for public clients has evolved, and the only viable defense is the cryptographic binding provided by PKCE.
The mechanism of PKCE transforms the authorization code from a static credential into a dynamic, one-time key that is useless without the client's internal state. This ensures that even if the network is hostile, the token exchange remains secure.
Common Pitfalls
Even with the correct flow logic, implementation errors can undermine security. Be vigilant about these common mistakes:
- State Parameter Misuse: Failing to generate a cryptographically random
statevalue or neglecting to validate it upon return leaves the application vulnerable to CSRF attacks. Thestatemust be unique per session and verified strictly before exchanging the code. - Verifier Storage Risks: Storing the
code_verifierinlocalStorageorsessionStorageexposes it to XSS attacks if the application is compromised. While sometimes necessary for redirect flows in SPAs, the verifier should ideally reside in volatile memory (JavaScript variables) and be wiped immediately after use. - Insecure Challenge Methods: Using the
plaincode_challenge_methoddefeats the purpose of PKCE. If thecode_verifieris sent as the challenge, any attacker who intercepts the initial redirect can simply copy the verifier to complete the token exchange later.
Practical Takeaways
Adopt these mental models to ensure a secure implementation:
- Always Use S256: Never default to
plain. TheS256method is the only secure option for public clients. - Never Store Verifier Persistently: Treat the
code_verifieras ephemeral data. Clear it from memory or storage the moment the token exchange succeeds. - State is Mandatory: The
stateparameter is not optional; it is the primary defense against CSRF. Never skip validation.
FAQ
Q: What is the difference between the plain and S256 methods?
A: The plain method sends the code_verifier directly as the code_challenge, offering no protection if the initial request is intercepted. The S256 method sends a SHA-256 hash of the verifier, ensuring the verifier itself remains hidden until the final token exchange.
Q: Why is the state parameter still needed if we have PKCE?
A: PKCE protects against the interception of the authorization code. The state parameter protects against Cross-Site Request Forgery (CSRF), ensuring the authentication response is intended for the specific user session that initiated the request. They address different attack vectors.
Q: Does SHA-256 hashing impact performance? A: No. The computational cost of generating a SHA-256 hash is negligible on modern devices and does not introduce noticeable latency for the user.
Conclusion
The OAuth 2.0 Authorization Code Flow with PKCE represents the necessary evolution for securing public clients like SPAs and mobile applications. By replacing the reliance on a client_secret with a cryptographic challenge-response mechanism, PKCE effectively neutralizes the authorization code interception vulnerability. Developers implementing authentication for public clients must adopt PKCE as defined in RFC 7636, specifically using the S256 challenge method, to ensure strong security against network-level attacks.
Related posts
OAuth 2.0 Fundamentals: Grant Types Explained Simply
A clear explanation of OAuth 2.0 grant types including authorization code, client credentials, and PKCE for secure API access.
Implementing and Validating Discovery in Your Client
A technical walkthrough for backend developers on implementing OAuth 2.1 discovery, issuer validation, and strict discovery document validation using OpenIDConnectConfigurationRetriever.
Decoding client_secret_basic Default in RFC 8414
An examination of the token_endpoint_auth_methods_supported metadata field in RFC 8414 and why client_secret_basic remains the default authentication method.