PKCE for SPA authentication
Why single-page apps need Proof Key for Code Exchange, how the verifier and challenge work, and when a BFF is the better place for tokens.
A SPA cannot keep a client secret. Anything you ship to the browser is public. That is the whole reason PKCE exists.
Proof Key for Code Exchange stops an attacker who steals an authorization code from exchanging it for tokens. The SPA proves it is the same client that started the login by presenting a code verifier that only it knows. If you are doing OAuth or OpenID Connect in a single-page app, PKCE is not optional polish. It is the baseline.
The problem PKCE solves
Classic authorization code flow assumed a confidential client. The server held a client secret and traded code plus secret for tokens. A SPA has nowhere safe to put that secret.
Without PKCE, an authorization code intercepted from a redirect or a leaked history entry can be redeemed by someone else. With PKCE, redemption requires the original code verifier. The attacker has the code. They do not have the verifier.
How the flow works
Before redirecting to the identity provider, the SPA creates a high-entropy code verifier. It derives a code challenge from that verifier, usually SHA-256, then base64url encodes it. The authorize request sends the challenge and the method. The verifier stays in browser storage only for the duration of the login.
After redirect, the SPA sends the authorization code and the original verifier to the token endpoint. The provider hashes the verifier and checks it against the challenge from the start of the flow. Match, then tokens. Mismatch, then deny.
function base64UrlEncode(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
let str = "";
bytes.forEach(b => {
str += String.fromCharCode(b);
});
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function createPkcePair() {
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64UrlEncode(verifierBytes.buffer);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier)
);
const challenge = base64UrlEncode(digest);
return { verifier, challenge, method: "S256" as const };
}
export async function startLogin() {
const pkce = await createPkcePair();
sessionStorage.setItem("pkce_verifier", pkce.verifier);
const url = new URL("https://auth.example.com/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", process.env.NEXT_PUBLIC_AUTH_CLIENT_ID!);
url.searchParams.set("redirect_uri", window.location.origin + "/callback");
url.searchParams.set("scope", "openid profile email");
url.searchParams.set("code_challenge", pkce.challenge);
url.searchParams.set("code_challenge_method", pkce.method);
url.searchParams.set("state", crypto.randomUUID());
window.location.assign(url.toString());
}
export async function finishLogin(code: string) {
const verifier = sessionStorage.getItem("pkce_verifier");
if (!verifier) throw new Error("Missing PKCE verifier");
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: process.env.NEXT_PUBLIC_AUTH_CLIENT_ID!,
code,
redirect_uri: window.location.origin + "/callback",
code_verifier: verifier,
});
const res = await fetch("https://auth.example.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
sessionStorage.removeItem("pkce_verifier");
if (!res.ok) throw new Error("Token exchange failed");
return res.json();
}
SPA rules that keep PKCE honest
Use S256. Plain challenge method exists for legacy cases. You should not need it in a modern browser.
Pair PKCE with state or nonce checks so you also defeat CSRF on the redirect. PKCE protects the code. State protects the browser session that started the login.
Prefer authorization code with PKCE over implicit flow. Implicit puts tokens in the URL fragment. That era should stay closed.
- Public client: no client secret in the SPA bundle.
- Short-lived verifier storage: sessionStorage for the login round-trip is enough.
- Exact redirect URI match at the identity provider.
- Refresh tokens only if the provider and your threat model allow it for public clients.
SPA versus BFF
PKCE is what you use when the browser is the OAuth client. A BFF changes the shape. The browser talks to your origin. Your backend is the confidential client, often with cookies instead of tokens in JavaScript.
I use both patterns on different products. TrueFit-style same-origin BFFs keep tokens off the client. Admin SPAs and tooling against an identity provider like Keycloak still need PKCE when the SPA talks to the IdP directly. Fira's identity story is Keycloak-centred. Any browser client in that world should assume PKCE on the public client path.
type AuthShape =
| { kind: "spa-public-client"; pkce: true }
| { kind: "bff-confidential"; browserHoldsTokens: false };
function recommend(shape: AuthShape) {
if (shape.kind === "spa-public-client") {
return "authorization code + PKCE + state";
}
return "BFF cookie session; tokens stay server-side";
}
Common mistakes
Reusing a verifier across logins. Generate a fresh pair every time.
Storing the verifier forever in localStorage. Clear it after exchange or failure.
Sending the verifier on the authorize request. Only the challenge goes there.
Mixing implicit and code flows while "adding PKCE later". Commit to code plus PKCE.
Skipping HTTPS on redirect URIs outside local development. Tokens and codes over cleartext undo the rest of the work.
What good looks like
The SPA starts login with a new PKCE pair and state. The IdP returns a code. The SPA redeems code plus verifier. Tokens are handled according to your architecture: in memory with care, or better still handed into a BFF session model when the product allows it.
PKCE will not fix XSS. If script can run in your origin, it can start flows and read what the page can read. Reduce XSS, use tight CSP, and do not treat PKCE as a substitute for basic web hardening.
For public SPAs, though, PKCE is the difference between an authorization code that is useful stolen and one that is not. Ship it by default.