Implementing OAuth 2.0 Authorization Code Flow with PKCE for Public Clients
A practical task guide for implementing OAuth 2.0 Authorization Code with PKCE in public clients such as SPAs and mobile apps, covering prerequisites, the code exchange, validation checks, and recovery paths.
18 Jan 2026, 08:53 UTC

The problem: public clients cannot keep secrets
A single-page application (SPA) or mobile app runs on a device the user controls, so any embedded credential can be extracted. The OAuth 2.0 Authorization Code flow with Proof Key for Code Exchange (PKCE, RFC 7636) solves this by removing the client secret entirely and binding each authorization request to a one-time, high-entropy secret generated on the device. The useful takeaway is that a correctly implemented PKCE flow prevents authorization code interception without requiring confidential storage in the client.
Desired outcome
Your application obtains access and ID tokens through a redirect-based exchange where the authorization server can prove that the party redeeming the code is the same party that started the flow. The client never transmits the original secret in the front channel, and a stolen authorization code is useless without the matching code verifier.
Prerequisites
- A registered public client at your authorization server, meaning a client type that cannot hold a confidential credential.
- A redirect URI that is whitelisted in the client registration and matches the value you send at runtime.
- A cryptographically secure random generator available in your runtime (for example, the Web Crypto API in browsers or the
cryptomodule in Node.js). - Authorization server support for the
S256code challenge method. PKCE is widely supported, but you should confirm this before rollout.
Step 1: Generate the code verifier and S256 challenge
The code verifier is a random string with sufficient entropy, typically 43–128 characters from the unreserved URL character set. The code challenge is the Base64-URL-encoded SHA-256 hash of that verifier. You send the challenge in the authorization request and keep the verifier locally until the token exchange.
Run the following in your client application or a local development script to generate a pair. Replace {RUNTIME} with your actual environment.
// Example in a browser or Node.js runtime
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = base64UrlEncode(array);
const encoder = new TextEncoder();
const challengeBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(codeVerifier));
const codeChallenge = base64UrlEncode(new Uint8Array(challengeBuffer));
function base64UrlEncode(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Risk: Some older libraries default to the plain method or use weak transforms. Pin S256 and test the actual derivation rather than assuming the library output is correct.
Step 2: Redirect the user to the authorization endpoint
Construct the authorization URL with the challenge and method. Do not include the verifier here. The following is a template; substitute your own values.
GET {AUTH_SERVER}/authorize?
response_type=code&
client_id={CLIENT_ID}&
redirect_uri={REDIRECT_URI}&
scope={SCOPES}&
state={OPAQUE_STATE}&
code_challenge={CODE_CHALLENGE}&
code_challenge_method=S256
Navigate the user agent to this URL. The authorization server authenticates the user, obtains consent, and redirects back to your redirect_uri with a short-lived authorization code and the state value you supplied.
Step 3: Exchange the authorization code for tokens
When your app receives the redirect, extract the code and state parameters. Verify that state matches what you stored. Then make a direct POST request from your client to the token endpoint. A public client sends its identifier but never a secret.
Run this from your client application runtime or from a terminal for local testing. Replace the placeholders.
POST {AUTH_SERVER}/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code={AUTHORIZATION_CODE}&
redirect_uri={REDIRECT_URI}&
client_id={CLIENT_ID}&
code_verifier={CODE_VERIFIER}
Expected result: A JSON response containing an access token, usually an ID token if OpenID Connect scopes were requested, and often a refresh token. Risk: Authorization codes are single-use and short-lived; replaying a code will fail.
Expected checks
- Challenge method enforcement: If your server policy requires
S256, a request withcode_challenge_method=plainshould be rejected. If it is accepted, your server is not enforcing the stronger transform. - Verifier mismatch: Sending a missing or incorrect
code_verifiershould produce aninvalid_granterror. This confirms the server is actually checking the binding. - Audience and client match: Inspect the returned token (or use an introspection endpoint if available) to confirm the
audorclient_idclaim matches your public client registration and that the granted scopes are what you requested.
Recovery options
- Fresh verifier on
invalid_grant: If the token exchange fails withinvalid_grant, do not retry the same verifier. Restart the flow from Step 1 with a new random verifier and challenge. - Refresh token rotation: If your provider issues refresh tokens to public clients, use its rotation policy. Each refresh should return a new refresh token and invalidate the previous one, limiting the damage from theft.
- Backend-for-frontend fallback: If the public client cannot securely store even transient state, or if you need stricter token handling, move the confidential exchange to a backend-for-frontend (BFF) component. The BFF acts as a confidential client, holds the client secret, and brokers tokens to the browser or mobile app.
Limitations and verification
PKCE prevents code interception, but it does not make a public client confidential. Do not use the implicit flow, and never embed a client secret in SPA or mobile code. Also, authorization server support for PKCE is widespread but not universal; probe the server metadata or documentation before rollout.
To verify your implementation locally, initiate the flow in a browser and open developer tools. Inspect the authorization URL to confirm code_challenge is present and code_verifier is absent from the front channel. Then run an integration test that intentionally sends a wrong code_verifier to the token endpoint and asserts an invalid_grant response before sending the correct one. If your provider offers token introspection or a debug endpoint, use it to confirm the issued token’s client and scope.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.