OAuth PKCE for SPAs — Public Clients Without a Client Secret

(Updated: July 16, 2026 ) OAuth PKCE security SPA

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

  1. App creates a high-entropy random code_verifier (43–128 characters from the unreserved set).
  2. Stores the verifier (memory or sessionStorage for the round-trip), sends code_challenge = BASE64URL(SHA256(verifier)) on the authorize URL with code_challenge_method=S256.
  3. User authenticates at the IdP; app receives an authorization code on the redirect URI.
  4. Token request includes the original verifier — the authorization server verifies it matches the challenge.
  5. 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.

BadBetter
http://localhost:* wide openExplicit ports you actually use
https://app.example.com vs ...com/ mismatchRegister both only if required; pick one
Custom scheme for “any app”Claim-specific reverse-domain schemes
Registering production + random preview hosts foreverShort-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

SymptomCause
invalid_grantVerifier lost on full page reload / mismatch
redirect_uri_mismatchwww, slash, or env mismatch
Works in Chrome, fails in WebViewCustom scheme / third-party cookie blocking
Infinite authorize loopClock skew, cookie blocking, wrong audience
invalid_client on public clientAS 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.