PKCE for Single-Page Apps

SecurityOAuthSPAAuthentication
Share on LinkedIn

OAuth tutorials from 2018 still show response_type=token and client_secret in React env vars. Both are wrong for browser apps. PKCE (Proof Key for Code Exchange) turns the authorization code into a one-time pairing: your SPA generates a random verifier, sends a hash challenge to the authorize endpoint, then proves possession when exchanging the code. Attackers who sniff the redirect ?code= cannot redeem it without the verifier that never left the session.

Generate verifier and challenge

function randomVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

async function challengeFromVerifier(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64UrlEncode(new Uint8Array(digest));
}

Store code_verifier in sessionStorage for the auth round trip—cleared after exchange. Use S256 method; plain is for constrained clients only.

Validate this in staging with production-like data volume before declaring done. Capture metrics baseline the week before change and compare for seven days after—subtle regressions hide in aggregates until a large tenant hits the path. Update the on-call runbook with the failure signature and rollback command so responders need not rediscover steps during an incident.

Authorization request

GET https://auth.example.com/oauth/authorize?
  response_type=code
  &client_id=spa_client_id
  &redirect_uri=https://app.example.com/callback
  &scope=openid profile
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &state=CsrfRandomState

Validate state on callback against sessionStorage to block CSRF.

Validate this in staging with production-like data volume before declaring done. Capture metrics baseline the week before change and compare for seven days after—subtle regressions hide in aggregates until a large tenant hits the path. Update the on-call runbook with the failure signature and rollback command so responders need not rediscover steps during an incident.

Token exchange

const res = await fetch("https://auth.example.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    client_id: SPA_CLIENT_ID,
    code: authorizationCode,
    redirect_uri: REDIRECT_URI,
    code_verifier: sessionStorage.getItem("code_verifier")!,
  }),
});

No client_secret in browser. Token endpoint must require matching verifier.

Validate this in staging with production-like data volume before declaring done. Capture metrics baseline the week before change and compare for seven days after—subtle regressions hide in aggregates until a large tenant hits the path. Update the on-call runbook with the failure signature and rollback command so responders need not rediscover steps during an incident.

BFF pattern for refresh tokens

Browser → BFF (/session/login) → IdP
IdP → BFF (/callback) sets HttpOnly cookie
Browser → BFF (/api/*) with cookie
BFF attaches access token server-side

Refresh tokens never touch JavaScript. BFF validates CSRF on mutating routes.

Validate this in staging with production-like data volume before declaring done. Capture metrics baseline the week before change and compare for seven days after—subtle regressions hide in aggregates until a large tenant hits the path. Update the on-call runbook with the failure signature and rollback command so responders need not rediscover steps during an incident.

Redirect URI discipline

Register exact URIs—no wildcards in production. Reject open redirects chained off callback parameters.

Validate this in staging with production-like data volume before declaring done. Capture metrics baseline the week before change and compare for seven days after—subtle regressions hide in aggregates until a large tenant hits the path. Update the on-call runbook with the failure signature and rollback command so responders need not rediscover steps during an incident.

CSP and XSS as remaining risk

PKCE stops code interception; XSS still steals tokens. Enforce strict CSP, sanitize HTML, and keep access tokens short-lived (5–15 minutes).

CSP and XSS remain risk after PKCE—short-lived access tokens limit exposure. BFF pattern keeps refresh tokens in HttpOnly cookies never touched by JavaScript.

Redirect URI discipline: exact matches in production, no open redirect chains off callback parameters. Register separate clients for web and mobile rather than sharing redirect URIs loosely.

Document refresh failure UX—silent re-login versus explicit session expired messaging affects support volume and security perception.

Validate this in staging with production-like data volume before declaring done. Capture metrics baseline the week before change and compare for seven days after—subtle regressions hide in aggregates until a large tenant hits the path. Update the on-call runbook with the failure signature and rollback command so responders need not rediscover steps during an incident.

Document the decision, owner, and rollback path in your team wiki the same week you ship. Future you will not remember which environment variable toggled the behavior unless it is written next to the runbook entry and linked from the alert. That habit costs ten minutes per change and saves hours when pagination or auth misbehaves under a single large tenant.

Run the change through your standard PR checklist: tests, observability, and a two-minute rollback drill in staging. Small operational habits accumulate into systems that survive on-call nights without heroics.

Share a short write-up in your engineering channel after rollout: what shipped, what metric you watch, and who owns follow-up. That closes the loop for teammates who were not in the PR and surfaces gaps in docs before the next person repeats the same investigation.

Prefer boring, repeatable process over one heroic migration weekend.

Treat operational readiness as part of definition-of-done: dashboards, alerts, runbook links, and a named owner. Skipping those steps ships code that works in demo and fails quietly in production until a customer or auditor finds the gap. Review this checklist on every auth-related change, no matter how small the diff looks in the pull request.

Resources

Frequently asked questions

Why is PKCE required for SPAs?

SPAs cannot hold client secrets—JavaScript bundles are public. Without PKCE, an attacker who intercepts the authorization code can exchange it at the token endpoint. PKCE binds the code to a verifier generated by the legitimate app instance, so stolen codes are useless without the verifier stored in that browser session.

Should SPAs store refresh tokens in localStorage?

Avoid localStorage for refresh tokens—they are readable by any XSS vulnerability. Prefer HttpOnly Secure SameSite cookies set by a backend-for-frontend (BFF) or use refresh token rotation with strict Content Security Policy and short access token TTL if pure front-channel is unavoidable. Memory-only storage loses session on tab close but reduces persistence risk.

Can I use implicit flow instead?

No. Implicit flow returns access tokens in the URL fragment and is deprecated in OAuth 2.1. Authorization Code with PKCE is the standard for browser apps. Implicit flow exposed tokens to referrer leaks and browser history without refresh rotation benefits.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →