Adding PKCE to OAuth 2.0: A Step‑by‑Step Guide for Public Clients
Add PKCE to your OAuth 2.0 flow to protect public clients from code interception attacks. This guide walks through the setup, key steps, and security tips.
08 May 2026, 09:35 UTC

Desired Outcome
Secure a public OAuth 2.0 client (mobile app, SPA, or console app) against authorization code interception by adding Proof Key for Code Exchange (PKCE). The result is a token request that the server can validate only if the original client presented the same verifier.
Prerequisites
- OAuth 2.0 authorization server that supports PKCE (most modern providers do).
- Client application codebase with network access to the
/authorizeand/tokenendpoints. - Access to a cryptographic library for random bytes and SHA‑256 hashing.
- Secure temporary storage on the client (in‑memory, secure cookie, or platform‑specific keychain).
Generating the Code Verifier and Challenge
The code_verifier is a high‑entropy, 43–128 character string. The code_challenge is derived from it.
// Node.js example
const crypto = require('crypto');
const codeVerifier = crypto.randomBytes(32).toString('hex'); // 64‑char hex
// S256 transformation
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
const codeChallenge = hash.toString('base64url');
Store codeVerifier securely until the token exchange. Do not log it.
Modifying the Authorization Request
Include the challenge and its method in the query string:
GET https://auth.example.com/authorize?
client_id=YOUR_CLIENT_ID
&response_type=code
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
Replace CODE_CHALLENGE with the value from the previous step. If you choose the plain method, omit the hash step and set code_challenge_method=plain, but S256 is strongly recommended.
Token Exchange with the Verifier
When the user authorizes, the server redirects to your redirect_uri with a code. Exchange it for tokens, sending the original code_verifier:
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=YOUR_CLIENT_ID
&code_verifier=CODE_VERIFIER
The authorization server hashes the received code_verifier (if S256 was used) and compares it to the stored challenge. Mismatch results in invalid_grant.
Expected Checks & Validation
- The
code_challengeparameter must be present in the initial request. - The server must return
400 Bad Requestifcode_challengeis missing. - After token exchange, the server should return an
access_tokenonly if the verifier matches. - Verify the token’s
issandaudclaims match your configuration.
Common Pitfalls & Recovery
- Verifier lost or overwritten – the flow fails. Keep the verifier in a single, short‑lived storage scope.
- Using plain method with weak verifier – increases risk. Prefer
S256. - Logging the verifier – expose secrets. Strip from logs and monitoring.
- Server not supporting PKCE – check the provider’s documentation or use the
response_type=codeflow without PKCE.
Example Implementation (Express + Passport)
Below is a minimal Express route that initiates PKCE and handles the callback. Replace placeholders with your own values.
const express = require('express');
const crypto = require('crypto');
const fetch = require('node-fetch');
const app = express();
app.get('/auth', (req, res) => {
const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest().toString('base64url');
// Store verifier in session or secure cookie
req.session.codeVerifier = codeVerifier;
const authUrl = `https://auth.example.com/authorize?` +
new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
response_type: 'code',
redirect_uri: 'https://app.example.com/callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
res.redirect(authUrl);
});
app.get('/callback', async (req, res) => {
const { code } = req.query;
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) return res.status(400).send('Missing verifier');
const tokenResp = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: codeVerifier
})
});
const tokenData = await tokenResp.json();
// Handle tokenData (store, use, etc.)
res.send('Logged in');
});
app.listen(3000, () => console.log('App listening on 3000'));
Comparison: Plain vs S256
| Method | Security Level | Implementation Complexity |
|---|---|---|
| plain | Low – verifier can be brute‑forced; vulnerable to interception. | None – no hashing required. |
| S256 | High – SHA‑256 hash mitigates brute‑force and replay attacks. | Requires SHA‑256 support (most libraries provide it). |
Testing the Flow
- Run the example app locally and navigate to
/auth. - Authorize the app in the browser; you should be redirected back to
/callback. - Inspect the network tab for the
/tokenPOST request. Verify thatcode_verifieris present. - Use a tool like Postman to repeat the token request without
code_verifierand confirm the server rejects it. - Check the token’s
audandissclaims match your client configuration.
Summary
PKCE turns the standard Authorization Code flow into a secure exchange for public clients. By generating a random verifier, deriving a challenge, and requiring the verifier at token exchange, you protect against interception and replay attacks with minimal code changes. Follow the steps above, choose the S256 method, and verify your provider’s support to keep your users safe.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.