Proof Key for Code Exchange: A developer’s guide
OAuth 2.0’s authorization code flow relies on a client secret to prove the client’s identity when exchanging an authorization code for tokens. But what happens when your “client” is a mobile app, a single-page app, or a CLI tool? (environments where you can’t safely store secrets)
That’s where PKCE (Proof Key for Code Exchange) comes in. Originally added to OAuth 2.0 for mobile, PKCE is now mandatory in OAuth 2.1 for all public clients. It thwarts interception and replay attacks by binding each authorization code to a one-time secret.
Why PKCE matters
- Public clients can’t hide secrets: Mobile apps and SPAs run on user-controlled devices or browsers: any embedded secret is extractable.
- Code interception is real: Without PKCE, an attacker on the same network or with a malicious browser extension could grab your auth code and redeem it.
- Replay attacks: Some servers didn’t strictly expire codes. PKCE ensures each code only works with its matching verifier.
With PKCE, each authorization code is cryptographically bound to a code verifier that only the legitimate client knows. Intercept a code? You still need the verifier to exchange it for tokens.
How to implement PKCE
Below is the end-to-end PKCE flow. Let’s dive into specifics of code verifiers, code challenges, authorization codes, and more.
1. Generate a code verifier and code challenge
In Node.js, create a random verifier and hash it:
// Node.js: generate code_verifier and code_challenge
import crypto from 'crypto';
const codeVerifier = crypto.randomBytes(64).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
console.log({ codeVerifier, codeChallenge });
What’s happening?
codeVerifier
is a 64-byte random string (your one-time secret).codeChallenge
is its SHA-256 hash, base64url-encoded, safe to send in URLs.
2. Redirect user with code challenge
Send the user to the authorization endpoint with your code_challenge
and method:
GET https://auth.example.com/oauth/authorize?
response_type=code&
client_id=your-client-id&
redirect_uri=http://localhost/callback&
code_challenge=YOUR_CODE_CHALLENGE&
code_challenge_method=S256&
scope=openid profile email
Key params:
code_challenge
= the hashed verifiercode_challenge_method
=S256
(SHA-256)- All other OAuth params (
client_id
,scope
, etc.)
3. Receive the authorization code
After user consent, your app’s redirect_uri receives:
https://localhost/callback?code=RECEIVED_AUTH_CODE&state=xyz
Store that code
and your original codeVerifier
. You’ll need both next.
4. Exchange code for tokens with verifier
Now redeem the code by supplying the original code_verifier
:
curl -X POST https://auth.example.com/oauth/token
-d grant_type=authorization_code
-d client_id=your-client-id
-d code=RECEIVED_AUTH_CODE
-d redirect_uri=http://localhost/callback
-d code_verifier=YOUR_CODE_VERIFIER
Example response:
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}
Why it’s secure: The server recomputes the hash of your
code_verifier
and compares it to the original challenge. If they don’t match, the exchange fails, so intercepted codes are useless.
Your turn
Ready to make your SPAs, mobile apps, and CLI tools PKCE-protected by default?
- Have you audited your flows for missing PKCE?
- What libraries or frameworks helped you integrate it seamlessly?
Drop your learnings or questions below, and let’s lock down those OAuth flows. If you’d like a deeper dive into PKCE, check out our blog here.