PKCE Explained: Why It's Mandatory in OAuth 2.1
Read anything about modern OAuth and PKCE shows up fast, usually as a requirement you can't skip. So let's take it from the ground up: what the auth-code interception attack actually is, how the code verifier and code challenge defeat it, and why OAuth 2.1 makes PKCE mandatory for every client. NamoID enforces PKCE on every authorization flow, first-party clients included, so by the end you'll know exactly what the issuer is checking and why.
PKCE stands for Proof Key for Code Exchange. It's pronounced "pixy" and defined in RFC 7636. It started as a fix for mobile apps, became a best practice for everyone, and is now baked into the OAuth 2.1 draft as a hard rule. For the wider context on what changed, see our companion post on OAuth 2.1 vs 2.0.
The auth-code interception attack
Why does PKCE exist? Because of a gap it closes in the plain authorization-code flow.
In a normal authorization-code flow, the user logs in at the identity provider, and the provider redirects back to your app with a short-lived code in the URL. Your app then exchanges that code at the token endpoint for tokens. The whole point: the code is useless on its own. It has to be exchanged.
The trouble is the redirect. On a mobile device or in a browser, that redirect URL can leak. A malicious app can register the same custom URL scheme as yours and receive the redirect. A logging proxy, a browser extension, or a referrer header can capture the URL. Grab the code before your app exchanges it, with a public client (no secret), and an attacker exchanges it themselves and walks off with tokens.
That's the authorization-code interception attack, documented in detail for native apps in RFC 8252, OAuth 2.0 for Native Apps. Confidential web apps were partly protected, since the token exchange also needs a client secret the attacker doesn't have. Public clients (single-page apps, mobile apps) had no such cover. PKCE hands every client a per-request secret that never travels in the vulnerable redirect.
The code verifier and code challenge
PKCE adds two values to the flow: the code verifier and the code challenge.
The code verifier is a high-entropy random string your app generates fresh for every authorization request. RFC 7636 puts it between 43 and 128 characters, using the unreserved URL character set. Think of it as a one-time password your app makes up and keeps secret in memory.
The code challenge is derived from the verifier. With the recommended S256 method, the challenge is the base64url-encoded SHA-256 hash of the verifier:
code_challenge = base64url( SHA256( code_verifier ) )
The trick is direction. SHA-256 is a one-way hash. You can go from the verifier to the challenge, but never back. So your app sends the challenge out in the open during the authorization request and gives nothing away. The secret verifier stays inside your app until the token exchange.
Here's the full relationship at a glance:
| Value | When sent | Travels in redirect | Reversible |
|---|---|---|---|
code_verifier | At token exchange (back channel) | No | n/a (it is the secret) |
code_challenge | At authorization request | Yes | No (SHA-256) |
code_challenge_method | At authorization request | Yes | S256 or plain |
Always use S256. The plain method sends the verifier as the challenge with no hashing, which defeats the purpose the moment the authorization request leaks. That's exactly why OAuth 2.1 allows only S256. To check support, read the issuer discovery document, where code_challenge_methods_supported lists the accepted methods.
The full PKCE flow
Putting it together, here is the complete authorization-code flow with PKCE, step by step.
- Your app generates a random
code_verifierand computes thecode_challengefrom it. - Your app redirects the user to the authorization endpoint, sending
code_challengeandcode_challenge_method=S256. The issuer stores the challenge against the issuedcode. - The user authenticates. The issuer redirects back to your app with the
code. - Your app calls the token endpoint with the
codeand the originalcode_verifier. - The issuer hashes the verifier it just received, compares it to the challenge it stored in step 2, and only issues tokens if they match.
Intercept the code in step 3 and you're stuck. Step 4 needs the matching code_verifier, which never left the app. The intercepted code is now worthless.
A real token request looks like this:
curl -X POST https://issuer.example.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://app.example.com/callback" \
-d "client_id=your-client-id" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"No client secret in that request. The code_verifier is what proves your app is the same app that started the flow.
Public vs confidential clients
PKCE matters most for public clients, but OAuth 2.1 requires it for both. The distinction comes down to whether the client can keep a secret.
| Client type | Examples | Can hold a secret? | Why PKCE matters |
|---|---|---|---|
| Public | SPAs, mobile apps, desktop apps | No | The only per-request proof the app can offer at token exchange |
| Confidential | Server-side web apps with a backend | Yes | Adds defense in depth on top of the client secret |
For SPAs and mobile apps, this isn't optional thinking. A single-page app ships its source to the browser, so any embedded secret is readable by anyone who opens dev tools. A mobile app can be decompiled. These clients genuinely cannot keep a secret, so PKCE is what makes the authorization-code flow safe for them. It's also why the OAuth 2.0 implicit grant, which handed tokens straight back in the redirect, is removed in OAuth 2.1.
Confidential clients still benefit. If your client secret ever leaks, an attacker still needs the per-request verifier to use an intercepted code. That's exactly why NamoID requires PKCE even on trusted first-party clients. No implicit grant, no bypass.
Implementing PKCE correctly
Most well-maintained OAuth and OIDC libraries generate the verifier and challenge for you. Prefer them over hand-rolling crypto. But if you do generate the values yourself, the two operations look like this in TypeScript using the Web Crypto API:
function base64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// 1. code_verifier: 32 random bytes -> 43-char base64url string
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = base64url(randomBytes);
// 2. code_challenge: base64url(SHA-256(verifier))
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(codeVerifier),
);
const codeChallenge = base64url(new Uint8Array(digest));A few rules to get right:
- Use a cryptographically secure random source, like
crypto.getRandomValues, neverMath.random. - Generate a fresh verifier for every authorization request. Do not reuse one.
- Store the verifier somewhere it survives the redirect but is not exposed, such as
sessionStoragefor an SPA or secure storage on mobile. Clear it after the exchange. - Send
code_challenge_method=S256, notplain. - Pair PKCE with the
stateparameter to defend against CSRF on the redirect. PKCE protects the code,stateprotects the round trip. They are not the same defense.
For a deeper look at what the issuer verifies and where PKCE fits among the other OAuth 2.1 changes, the OAuth 2.1 vs 2.0 post covers the full picture. Weighing how much of this to build yourself versus adopting an issuer that enforces it for you? Our build vs buy auth in India guide walks through the trade-offs.
Common mistakes
These are the PKCE errors we see most often.
- Using
plaininstead ofS256. If the authorization request leaks,plainexposes the verifier directly. Always hash. - Reusing a verifier across requests. A reused verifier weakens the per-request guarantee. Generate one each time.
- Losing the verifier across the redirect. If you store it in memory that gets wiped on navigation, the token exchange fails. Use
sessionStorageor secure mobile storage. - Treating PKCE as a replacement for
state. They solve different problems. Keep both. - Assuming a client secret makes PKCE unnecessary. Defense in depth is the point. OAuth 2.1 wants both where available.
- Skipping PKCE on first-party clients. "We trust this app" is not a security boundary. A trusted public client is still a public client. NamoID is built so no path skips PKCE.
FAQ
What does PKCE protect against?
PKCE protects against the authorization-code interception attack, where an attacker captures the code from the redirect and tries to exchange it for tokens. Without the matching code verifier, which never leaves your app, the intercepted code is useless.
Is PKCE required in OAuth 2.1?
Yes. The OAuth 2.1 draft makes PKCE mandatory for all clients using the authorization-code flow, public and confidential alike. It also removes the implicit grant. You can read the in-progress spec at oauth.net.
Do confidential clients with a secret still need PKCE?
Under OAuth 2.1, yes. The client secret and PKCE defend different failure modes. If your secret leaks, the per-request verifier still blocks an intercepted code from being exchanged. It is defense in depth, not redundancy.
What is the difference between the code verifier and code challenge?
The code verifier is the random secret your app generates and keeps. The code challenge is the SHA-256 hash of the verifier, which your app sends openly in the authorization request. The hash is one-way, so the challenge can travel in the open while the verifier stays secret until the token exchange.
Where NamoID fits
NamoID is a privacy-first, India-first OIDC issuer that enforces PKCE on every authorization flow, with refresh-token rotation and RS256-signed tokens behind a single issuer URL. No implicit grant, no first-party bypass. Want to see how it works end to end? Book a 30-minute demo at calendly.com/polymindslabs/30min or reach the team at hello@namoid.in. The product ships soon, and we're happy to walk you through the flow before then.