PKCE for SPAs, deeper dive
Why PKCE, how to implement, common mistakes
PKCE (Proof Key for Code Exchange, RFC 7636) is mandatory for public OAuth2 clients (SPAs, mobile, desktop). Olympus enforces it. Here's the why and how.
Without PKCE: the attack
1. SPA initiates OAuth2: redirects to Hydra/authorize.
2. User authenticates.
3. Hydra redirects back with code=ABC.
4. SPA exchanges code for token.
ATTACK:
- Attacker intercepts the code (browser history, server logs, malicious browser extension, custom URI scheme on shared device).
- Attacker exchanges the code for a token.
- Without PKCE, code-to-token exchange has no client secret (public client), succeeds.With PKCE: the defense
1. SPA generates code_verifier = random(32 bytes).
2. SPA computes code_challenge = sha256(code_verifier).
3. SPA initiates OAuth2 with code_challenge.
4. User authenticates.
5. Hydra redirects back with code=ABC.
6. SPA exchanges code + code_verifier for token.
7. Hydra verifies sha256(code_verifier) == stored code_challenge.
ATTACK:
- Attacker intercepts code (still possible).
- Attacker tries to exchange, must present code_verifier.
- Attacker doesn't have it.
- Exchange fails.The code_verifier is held by the original client. Without it, the code is useless.
Implementation
Generating the verifier and challenge
function base64URLEncode(buffer: ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function pkce() {
const verifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64URLEncode(await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier)
));
return { verifier, challenge };
}Step 1: authorize URL
const { verifier, challenge } = await pkce();
sessionStorage.setItem("pkce_verifier", verifier);
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: `${origin}/callback`,
scope: "openid offline_access profile email",
state: crypto.randomUUID(),
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${HYDRA_URL}/oauth2/auth?${params}`;Step 2: callback exchange
const code = new URLSearchParams(window.location.search).get("code");
const verifier = sessionStorage.getItem("pkce_verifier");
const res = await fetch(`${HYDRA_URL}/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: `${origin}/callback`,
client_id: CLIENT_ID,
code_verifier: verifier!,
}),
});
const { access_token, id_token, refresh_token } = await res.json();
sessionStorage.removeItem("pkce_verifier");Methods: S256 vs plain
code_challenge_method = S256 | plain- S256: code_challenge = sha256(verifier). Recommended.
- plain: code_challenge = verifier. Defeats the purpose. Don't use.
Hydra accepts both but S256 is strongly preferred. Always use S256.
Verifier requirements
RFC 7636:
- 43-128 characters.
- Allowed:
[A-Z][a-z][0-9]-._~ - Generated from cryptographic randomness.
The example above uses 32 bytes → 43 chars base64url. Minimum.
For higher entropy, use 64 bytes → 86 chars.
Storage of verifier
Where to keep code_verifier between the auth redirect and the callback:
- sessionStorage: persists for tab session, lost on close. OK for SPAs.
- localStorage: persists indefinitely. Don't, XSS risk if lingering.
- Cookie: HttpOnly is most secure but requires server. For pure SPA, not directly applicable.
- In-memory: variable scope. Lost on reload. Doesn't survive the redirect.
For SPAs: sessionStorage is the practical choice.
Confidential clients
For backend confidential clients (server-to-server), PKCE is OPTIONAL. They have a client_secret that defends against code interception (similar reason).
But: adding PKCE to confidential clients is cheap and adds defense in depth. Many SDKs do it by default.
Hydra enforcement
Olympus's Hydra enforces PKCE for public clients (no client_secret):
hydra create client --token-endpoint-auth-method nonenone = public client. PKCE required.
If the client somehow doesn't send PKCE:
{
"error": "invalid_request",
"error_description": "Public OAuth2 clients are required to use PKCE."
}See Troubleshooting, OAuth2 PKCE required.
Common mistakes
Wrong base64url
Easy to mix up:
- Regular base64 uses
+/=. - base64url uses
-_and no=.
Hydra rejects regular base64.
// WRONG
btoa(verifier)
// RIGHT
btoa(verifier).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")Using verifier as challenge
// WRONG, plain method
const challenge = verifier;
// Hydra accepts, but security is gone
// RIGHT, S256
const challenge = base64URLEncode(sha256(verifier));Verifier lost between redirect
User navigates back, clears sessionStorage, retries. Verifier mismatch. Need to start the flow over.
Handle gracefully:
const verifier = sessionStorage.getItem("pkce_verifier");
if (!verifier) {
// Start fresh
return startOAuthFlow();
}Reusing the same verifier across flows
Each flow needs a NEW verifier. Don't generate once and reuse.
async function startFlow() {
const { verifier, challenge } = await pkce(); // FRESH every time
// ...
}Mobile / desktop
Same PKCE pattern applies. Use platform-appropriate storage:
- iOS: Keychain.
- Android: EncryptedSharedPreferences.
- Desktop: OS keyring (Keychain on Mac, Credential Manager on Windows).
Avoid raw files / unencrypted preferences.
Library choices
JS:
- oauth4webapi, generates and verifies PKCE.
- oidc-client-ts, full OIDC flow including PKCE.
iOS:
- AppAuth-iOS, official.
Android:
- AppAuth-Android, official.
Don't roll your own unless necessary.