OAuth PKCE for SPAs — Public Clients Without a Client Secret
Shipping an SPA with a client secret in the bundle is a classic Reddit roast. Anyone can extract it from the network tab or a source map. OAuth for browsers is public client + PKCE, not “hide the secret in Base64.” Authorization servers that still encourage implicit flow for SPAs are stuck in 2015; modern guidance is authorization code with Proof Key for Code Exchange.
PKCE in one walkthrough
- App creates a high-entropy random code_verifier (43–128 characters from the unreserved set).
- Stores the verifier (memory or sessionStorage for the round-trip), sends code_challenge =
BASE64URL(SHA256(verifier))on the authorize URL withcode_challenge_method=S256. - User authenticates at the IdP; app receives an authorization code on the redirect URI.
- Token request includes the original verifier — the authorization server verifies it matches the challenge.
- An attacker who steals only the code (open redirect, referrer leak, malicious browser extension timing) cannot exchange it without the verifier.
async function makePkce() {
const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
const challenge = base64Url(new Uint8Array(digest));
return { verifier, challenge };
}
Prefer battle-tested libraries (oidc-client-ts, AppAuth-style clients) over hand-rolled crypto unless you enjoy subtle Base64URL padding bugs.
Redirect URI hygiene
Redirect URIs must be an exact match registered at the IdP. Wildcards like https://*.example.com/* are how account takeovers happen.
| Bad | Better |
|---|---|
http://localhost:* wide open | Explicit ports you actually use |
https://app.example.com vs ...com/ mismatch | Register both only if required; pick one |
| Custom scheme for “any app” | Claim-specific reverse-domain schemes |
| Registering production + random preview hosts forever | Short-lived preview registrations or a BFF |
www vs apex, HTTP vs HTTPS, and trailing slashes are the top three “works in the docs, fails in prod” mismatches. Mobile WebViews add cookie and custom-scheme rules on top — test the real container early.
State, nonce, and mix-up
- state binds the redirect to the browser session that started login (CSRF on the redirect).
- For OIDC, nonce in the ID token proves the token was minted for this authentication.
- Multi-IdP setups need issuer checks so a code from IdP A cannot be redeemed at IdP B (mix-up).
Dropping state “to simplify” is how you get login CSRF. Log state mismatches as security events, not silent retries.
SPA vs Backend-for-Frontend
Pure SPA PKCE works for many apps: short-lived access tokens in memory, silent renew when the IdP allows. Many teams still add a BFF to:
- Hold refresh tokens in httpOnly, Secure, SameSite cookies
- Keep long-lived credentials off
localStorage/sessionStorage - Centralize token exchange so the browser never sees a refresh token
PKCE alone does not make XSS harmless. If an attacker runs script in your origin, they can start OAuth flows or exfiltrate whatever the page can read. Treat CSP, dependency hygiene, and cookie flags as part of the same design — not optional polish after “we added PKCE.”
Token storage trade-offs
Prefer memory-only access tokens when you can. sessionStorage / localStorage are XSS-readable. httpOnly cookies via a BFF keep tokens off JavaScript but need CSRF/SameSite care. There is no perfect browser-only vault — keep access-token TTL short and rotate refresh tokens where the IdP supports it.
Failure modes you will see in logs
| Symptom | Cause |
|---|---|
invalid_grant | Verifier lost on full page reload / mismatch |
redirect_uri_mismatch | www, slash, or env mismatch |
| Works in Chrome, fails in WebView | Custom scheme / third-party cookie blocking |
| Infinite authorize loop | Clock skew, cookie blocking, wrong audience |
invalid_client on public client | AS still expects a secret — misconfigured client type |
Migration off implicit
If you still have response_type=token or hybrid flows with tokens in the fragment: register a public client with PKCE required, switch to response_type=code + S256, stop parsing location.hash for access tokens, rotate any secrets that were ever shipped in the SPA, and audit analytics so they never log authorize URLs with codes.
Bottom line
No secrets in frontend bundles. Authorization code + PKCE with S256. Exact redirect URIs. State (and nonce for OIDC). Prefer a BFF when refresh tokens and XSS are in scope. Implicit flow belongs in a museum — and “we Base64’d the client secret” belongs next to it.