PKCE: Why Every OAuth Client Needs It in 2026 (Even the Ones That Used to Be Fine Without)
PKCE used to be a mobile-only thing. OAuth 2.1 makes it mandatory for everyone. Here is what the protection actually does, why a confidential web app needs it too, and the eight-line implementation that closes the authorization-code-interception attack.
A code review comment lands: "Add PKCE to the OAuth flow." The author replies: "We are a confidential client with a real backend and a client_secret. PKCE is for mobile apps. We do not need it." The reviewer pushes back: "OAuth 2.1 makes PKCE mandatory for everyone." Neither person is precisely right, and the question of whether to add PKCE to a server-side web app keeps coming up in code reviews.
PKCE (Proof Key for Code Exchange, RFC 7636) was originally designed for public clients (mobile apps, single-page apps) that cannot keep a client secret. The reason: those clients had a known attack against them that required no secret to exploit. PKCE closed it. Then in 2023-2024, the OAuth working group decided every client should use PKCE, even confidential ones, and OAuth 2.1 (still a draft but increasingly the reference) makes it mandatory. The reasons are subtle and worth knowing.
This post is what PKCE actually does, the attack it defends against, why confidential clients now need it too, and the eight-line implementation in any language.
The attack PKCE was designed to stop#
Picture an OAuth Authorization Code flow without PKCE for a mobile app:
1. User opens MyApp on their phone.
2. MyApp redirects user to https://idp.example.com/authorize?
client_id=myapp
redirect_uri=myapp://oauth/callback
response_type=code
state=...
3. User authenticates at IdP.
4. IdP redirects user back to myapp://oauth/callback?code=ABC123
(custom URL scheme registered by MyApp on the device)
5. MyApp receives the code; exchanges it at the IdP token endpoint:
POST /token
grant_type=authorization_code
code=ABC123
redirect_uri=myapp://oauth/callback
client_id=myapp
6. IdP returns access_token, refresh_token, id_token.
The vulnerability is at step 4. On mobile, custom URL schemes (myapp://) can be registered by multiple apps. A malicious app on the same device can also register myapp://oauth/callback (or any prefix-matching scheme). When the IdP redirects, the OS may invoke the malicious app instead of (or in addition to) MyApp. The malicious app captures the authorization code.
In step 5, the malicious app exchanges the code at the IdP. Because public clients have no client_secret, no further proof is required. The IdP issues tokens to the attacker.
The same attack class applies to:
- Mobile apps (custom URL scheme hijacking).
- Single-page apps (a malicious browser extension reads the URL).
- Desktop apps with localhost redirects (a malicious local process binds the same port first).
The common thread: in public clients, the authorization code transits through a channel an attacker might also see, and there is no client_secret to gate the code exchange.
PKCE closes this by binding the code exchange to a secret that only the legitimate client knows.
PKCE is not a feature; it is a defense against a specific known attack. The original Authorization Code flow assumes the redirect channel is private (only the legitimate client can read the redirect URL). For mobile and SPA, that assumption is false. PKCE adds a per-request secret that the client proves possession of when redeeming the code. Even an attacker who captures the code cannot redeem it.
How PKCE actually works#
The protocol adds three things to the standard Authorization Code flow:
1. At the start of the flow, the client generates a random secret called
the code_verifier. 32 bytes minimum; URL-safe characters.
2. The client computes the code_challenge by SHA-256 hashing the verifier
and base64url-encoding the result.
code_challenge = base64url(SHA256(code_verifier))
3. The client sends the code_challenge in the /authorize request:
/authorize?response_type=code
&code_challenge=<challenge>
&code_challenge_method=S256
&...other normal params
4. The IdP records the code_challenge alongside the issued authorization
code. When the code is later redeemed, the IdP requires the client to
present the verifier; verifies SHA256(verifier) == challenge.
5. When the client exchanges the code at /token, it includes the verifier:
POST /token
grant_type=authorization_code
code=<code>
code_verifier=<verifier>
...
6. IdP validates: hash the verifier; compare to the stored challenge.
If match, return tokens. If not, reject.
The attacker who intercepts the authorization code does not have the verifier (which never leaves the legitimate client). Without the verifier, they cannot redeem the code. The interception is harmless.
Three properties make this work:
The verifier never transits through the redirect channel. It stays inside the legitimate client. The redirect carries only the challenge (and not always; some flows do not include it in the redirect at all, just at the token exchange).
The challenge is one-way. SHA-256 is not reversible. An attacker who sees the challenge cannot derive the verifier.
The verifier is per-request. Each /authorize call generates a fresh verifier. There is no long-lived secret to leak.
This is the entire mechanism. Eight lines of code in any language; the security improvement is dramatic.
Why confidential clients now need it too#
The historical position: confidential clients have a client_secret. The token endpoint requires both the code AND the client_secret to redeem. An attacker who steals the code cannot redeem it without the secret. PKCE adds nothing for confidential clients.
This held for years. Three things changed:
1. The client_secret is not always confidential. Engineering teams put client_secrets in:
- Source repositories (despite policy).
- Container images.
- CI logs.
- Backup files.
- Developer laptops.
Each leak gives an attacker who also has access to authorization codes (which they might via XSS, log access, etc.) the ability to mint tokens. PKCE adds defense in depth: the per-request verifier is harder to steal than the long-lived secret.
2. Authorization-code interception attacks evolved. New variants were discovered through 2020-2023 in confidential client setups too:
- Malicious browser extensions that read the redirect URL after the legitimate browser handler did.
- Logs that captured the redirect URL with the code.
- Bugs in OAuth libraries that misrouted codes.
PKCE provides a second layer that does not depend on the client_secret remaining secret.
3. OAuth 2.1 reasoned about complexity. Maintaining "PKCE for public clients only" requires deciding which client is which. Real systems are messy: a confidential client might fall back to non-confidential mode in some scenarios; a development setup might not have a real secret. The simpler rule "PKCE always" eliminates the decision.
OAuth 2.1 (draft, but the direction the working group is taking) makes PKCE mandatory for all clients. Implementation: every IdP supports it; every modern client library implements it; the cost is one additional parameter in two HTTP requests.
The implementation, in any language#
Eight lines in Python:
import secrets
import hashlib
import base64
# At the start of the auth flow
code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
# Store the verifier in the user's session or a server-side cache
session["pkce_verifier"] = code_verifier
# Send the challenge in the /authorize redirect
authorize_url = (
f"{IDP_AUTHORIZE_ENDPOINT}?"
f"client_id={CLIENT_ID}&"
f"response_type=code&"
f"redirect_uri={REDIRECT_URI}&"
f"scope=openid+profile+email&"
f"state={secrets.token_urlsafe(32)}&"
f"code_challenge={code_challenge}&"
f"code_challenge_method=S256"
)
return redirect(authorize_url)
# Later, in the callback handler
def callback(request):
code = request.args["code"]
verifier = session.pop("pkce_verifier", None)
if not verifier:
abort(400)
resp = requests.post(IDP_TOKEN_ENDPOINT, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET, # confidential clients only
"code_verifier": verifier, # PKCE verifier
}).json()
return store_session_and_redirect(resp)
The same eight lines (random verifier, SHA-256 hash, base64url encode) in any other language. JavaScript:
function generateVerifier() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return base64urlEncode(bytes);
}
async function generateChallenge(verifier) {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return base64urlEncode(new Uint8Array(hash));
}
function base64urlEncode(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
Go (using golang.org/x/oauth2):
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
func generateVerifier() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func generateChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// oauth2 v0.10+ has built-in support
oauth2Config := &oauth2.Config{...}
verifier := oauth2.GenerateVerifier()
// At /authorize
authURL := oauth2Config.AuthCodeURL(state,
oauth2.S256ChallengeOption(verifier))
// At /token exchange
token, err := oauth2Config.Exchange(ctx, code,
oauth2.VerifierOption(verifier))
Most modern OAuth libraries (Python's authlib, Node's openid-client, Java's Spring Security OAuth, .NET's Microsoft.Identity.Web) have first-class PKCE support. The integration is a parameter, not a refactor.
The two PKCE methods: S256 vs plain#
The spec defines two code_challenge_method values:
S256: code_challenge is SHA-256 of the verifier, base64url-encoded.plain: code_challenge is the verifier itself.
Always use S256. The plain method exists for clients that absolutely cannot do SHA-256 (essentially never in 2026). It defeats the purpose: an attacker who intercepts the redirect (which contains the challenge) gets the verifier directly.
Some IdPs reject plain outright. Those that accept it should not be accepted by you.
The IdP side: enforcing PKCE#
Modern IdPs let you require PKCE per app:
# Okta app config
pkce_required: true
# Auth0 app config
"oidc_conformant": true,
"token_endpoint_auth_method": "none" # public client; PKCE required
For confidential clients, you can configure pkce_required: true even with a client_secret. The IdP requires both: the secret AND the verifier. Defense in depth.
For public clients (mobile, SPA), the IdP does not even let you set a client_secret; PKCE is the substitute.
What PKCE does NOT replace#
A few things PKCE specifically does not protect against:
1. Stolen access tokens. PKCE protects the code-to-token exchange. Once tokens are issued, theft of the access token still works (until expiry).
2. Phishing. PKCE assumes the legitimate client is starting the flow. A phishing site that shows a fake login page and forwards credentials to the IdP can complete the entire flow including PKCE; the phisher acts as the legitimate client.
3. CSRF on the OAuth flow. That is what the state parameter is for. PKCE and state are complementary defenses.
4. Open redirect via redirect_uri. That is what exact-match redirect_uri validation is for at the IdP.
PKCE is one specific control for one specific attack. Use it; do not assume it does more than it does.
Common mistakes#
- PKCE turned off "to make it work." Some old clients had bugs with PKCE. Modern libraries do not. Do not disable.
- Reusing verifiers across flows. Each
/authorizecall needs a fresh verifier. Generate per request. - Storing the verifier in the URL. Some examples online do this; it defeats the protection. Store server-side or in a non-URL session token.
- Using
plaininstead ofS256. Defeats the purpose. Always S256. - Verifier too short. Must be 43-128 characters; 32 random bytes base64-encoded is 43+. Most language libraries handle this; verify if you wrote your own.
- Browser cache leaking the verifier. If you store the verifier in localStorage and never clear it, a later XSS reads it. Clear after use.
- State parameter and PKCE confused. They are different. State is a CSRF token in the redirect; PKCE is a verifier for the token exchange. You need both.
Quick reference: PKCE in two HTTP messages#
At /authorize:
GET /authorize?
...
&code_challenge=<base64url(SHA256(verifier))>
&code_challenge_method=S256
At /token:
POST /token
...
&code_verifier=<original verifier>
That is the entire wire-protocol addition. Two extra parameters, one in each direction, plus the eight lines of code that generate the verifier and challenge.
The mental model#
PKCE is a per-request shared secret between the client and the IdP, established by the client at flow start, proven by the client at token exchange. It does not depend on the redirect channel being private; it does not depend on the client_secret being secret; it does not depend on long-lived setup. Each flow has its own ephemeral secret.
For public clients, PKCE is the only authentication of the code exchange (since there is no client_secret to use). For confidential clients, PKCE adds a second layer that does not depend on secret hygiene.
The cost is two parameters and eight lines of code. The benefit is closing one of the most common token-theft attack classes. The OAuth working group decided this trade is so favorable that PKCE should be mandatory for all clients in OAuth 2.1.
In 2026, the right reflex when reviewing any OAuth integration is: where is the PKCE? If the answer is "we are confidential, we do not need it," update the answer. The cost is trivial; the protection is real.
OAuth 2.0 / 2.1 in depth, OIDC mechanics, and the full identity-flow security model are covered in the Identity and Trust for DevOps Engineers course. PKCE is one piece of a larger token-theft-defense pattern that includes audience binding, sender-constrained tokens (DPoP), and refresh-token rotation, all covered there.
More in Security
How GitHub Actions OIDC to AWS Actually Works (and the Eight Ways It Breaks)
AssumeRoleWithWebIdentity returns AccessDenied. The OIDC token looks valid. The trust policy looks right. The error message is useless. Eight specific causes, eight specific fixes, and a diagnostic that finds the right one in 30 seconds.
Read postcert-manager Renewed Your Certificate. Your App Still Serves the Old One. Why?
The certificate in the Secret is fresh. The pod is still serving the expired one. cert-manager did its job. Your app did not. The five renewal failures that bite production.
Read postYour JWT Validation Is Broken. Here Are the Eight Bugs That Caused Real Breaches.
JWTs look simple: a signed JSON blob, verify the signature, trust the claims. Almost every step of that has a known bug pattern that has caused real production breaches. Here is the catalog.
Read post