Frontend / Security / 07_oauth_oidc_for_spas.md

OAuth 2.0 / OIDC for SPAs — Auth Code + PKCE

Updated 6 interview angles 8 min read source
On this page16
  1. OAuth 2.0 quick mental model.
  2. OAuth vs OIDC — different things?
  3. Why is Implicit flow dead?
  4. Authorization Code with PKCE — the modern flow.
  5. Why PKCE? Wasn’t OAuth secure without it?
  6. Where do the tokens end up?
  7. PKCE flow with a BFF.
  8. What’s state for in OAuth?
  9. ID Token validation.
  10. Refresh tokens — how do they fit?
  11. Single Sign-On (SSO).
  12. Common OAuth mistakes.
  13. Gotchas
  14. Related
  15. Further reading
  16. Interview angle

OAuth 2.0 / OIDC for SPAs — Auth Code + PKCE

For modern single-page apps, the standard auth flow is OAuth 2.0 Authorization Code with PKCE (Proof Key for Code Exchange). It replaces the deprecated Implicit flow (tokens in URL hash → localStorage). For identity (who is the user?), add OpenID Connect (OIDC) on top — adds an ID token (JWT with user claims). The senior knowledge: why Implicit is dead, how PKCE prevents code interception, where tokens should end up (httpOnly cookies via a BFF pattern, not localStorage), and PKCE’s role in mobile/native too.

OAuth 2.0 quick mental model.

OAuth is delegated authorization — a user lets your app access resources on a third party (their Google calendar, GitHub repos) without sharing their password.

Roles:

  • Resource Owner — the user.
  • Client — your app.
  • Authorization Server — issues tokens (e.g., accounts.google.com).
  • Resource Server — the API the token grants access to (e.g., googleapis.com/calendar).

Flow types: Authorization Code (server-side apps, SPAs with PKCE, mobile), Client Credentials (machine-to-machine), Device Code (TVs, CLIs). Implicit and Resource Owner Password are deprecated.

OAuth vs OIDC — different things?

OAuth = authorization (“can this app access this resource?”). OIDC = identity (“who is the user?”) layered on top.

OAuth alone gives you an access token — opaque to you; it lets you call an API. OIDC adds:

  • An ID Token (JWT) with claims about the user (sub, email, name).
  • A userinfo endpoint for more profile data.
  • Standardized scopes (openid, profile, email).

If you want to log a user in via Google: use OIDC. If you only want to call an API on their behalf: OAuth suffices.

Why is Implicit flow dead?

The old SPA flow: redirect to https://auth.example.com/authorize?response_type=token, user logs in, redirected back to https://yourapp.com/#access_token=.... Your JS parses the fragment and stores the token.

Problems:

  1. Token in URL fragment — logged in browser history, referer headers, server logs (if URL leaks).
  2. No refresh tokens — short-lived access tokens require silent-refresh hacks (iframe with prompt=none).
  3. No code interception protection — anything that intercepts the redirect gets the token directly.

The OAuth 2.0 Security BCP (RFC 9700, January 2025) and the OAuth 2.1 draft both drop Implicit. Modern alternative: Auth Code + PKCE.

Authorization Code with PKCE — the modern flow.

  1. Client generates a code verifier (random ~64-char string) and code challenge = BASE64URL(SHA256(verifier)).

  2. Redirect to auth server with response_type=code and code_challenge:

    text
    https://auth.example.com/authorize?
      response_type=code&
      client_id=spa-client&
      redirect_uri=https://yourapp.com/callback&
      scope=openid profile email&
      state=<csrf>&
      code_challenge=<challenge>&
      code_challenge_method=S256
  3. User logs in, consents. Auth server redirects back with a one-time authorization code:

    text
    https://yourapp.com/callback?code=<code>&state=<csrf>

Exchanging the code

  1. Client POSTs the code + verifier to exchange for tokens:

    text
    POST /token
    grant_type=authorization_code
    code=<code>
    redirect_uri=https://yourapp.com/callback
    client_id=spa-client
    code_verifier=<verifier>
  2. Auth server verifies SHA256(verifier) === stored challenge. If yes, returns:

    json
    {
      "access_token": "...",
      "id_token": "...",
      "refresh_token": "...",
      "expires_in": 3600
    }

PKCE binds the redirect (where the code is delivered) to the token exchange (where the verifier is sent). Even if an attacker intercepts the code, without the verifier they can’t exchange it.

Why PKCE? Wasn’t OAuth secure without it?

Public clients (SPAs, mobile apps) can’t keep a client_secret. Without PKCE:

  • Attacker intercepts the authorization code (malicious app on the same device, redirect interception, history leak).
  • Attacker exchanges the code for tokens (no secret needed for public clients).

PKCE adds a per-flow secret (the verifier). Code without verifier is useless.

Originally for mobile (where redirect URIs can be hijacked by other apps), PKCE is now required for all public clients by RFC 9700, and recommended for confidential ones.

Where do the tokens end up?

The hard question. Two patterns:

Token in browser (SPA-only):

  • Access token: in-memory variable (best) or localStorage (worse).
  • Refresh token: localStorage (bad — XSS reads forever) or, awkwardly, in memory (lost on refresh).
  • Pros: simple, no backend needed.
  • Cons: XSS = token theft.

BFF (Backend-For-Frontend) pattern:

  • Your SPA’s backend (a thin server you control) handles the OAuth dance.
  • Tokens are stored server-side (in a session store, Redis).
  • Browser gets an httpOnly session cookie referencing the server session.
  • API calls go SPA → BFF → upstream API (BFF attaches the access token).
  • Pros: XSS can’t reach tokens. CSRF mitigated by SameSite cookies.
  • Cons: needs a server (no longer “static SPA”).

Modern best practice for SPAs: BFF pattern; tokens never touch the browser. RFC 10017 (OAuth 2.0 for Browser-Based Applications, August 2026) names three architectures, in decreasing order of protection:

Architecture Tokens live
BFF only on your server; the browser holds a session cookie
Token-mediating backend server gets tokens, hands the access token to JS
Browser-based client the SPA does the whole flow and holds tokens

The RFC’s warning on the last two: once attacker script runs in your origin it can start a fresh silent flow and get its own tokens, so sender-constraining them with DPoP (RFC 9449) limits replay elsewhere but does not stop XSS from acting in your page. “Refresh token in an HttpOnly cookie, access token in memory” is the token-mediating backend, not a BFF.

PKCE flow with a BFF.

  1. User clicks “Login with Google.”
  2. SPA redirects to /api/auth/login (your BFF).
  3. BFF generates state + PKCE verifier, stores in session, redirects to Google with challenge.
  4. User authenticates with Google.
  5. Google redirects to /api/auth/callback?code=... (BFF endpoint).
  6. BFF exchanges code + verifier for tokens — Google returns access, ID, refresh.
  7. BFF stores tokens in its session store; sets httpOnly session cookie on the browser.
  8. SPA does fetch("/api/users") — cookie attached. BFF authenticates request, attaches Google access token, calls Google API, returns result.

The browser never sees the tokens. Frameworks (NextAuth, Auth.js, Lucia, Better Auth) handle this end-to-end.

What’s state for in OAuth?

CSRF protection during the redirect. Client generates a random state, stores it (cookie or session), passes in the authorize URL. Auth server echoes it back in the callback. Client verifies match.

Without it, an attacker can complete an OAuth flow with their account, redirect the victim to the callback with the attacker’s code, victim’s app links the attacker’s account to the victim’s session.

Always required for security. PKCE doesn’t replace state — they protect different things.

ID Token validation.

The ID token is a JWT. Validate it:

  1. Signature against the IdP’s JWKs (https://provider/.well-known/jwks.json).
  2. iss (issuer) matches the expected provider.
  3. aud (audience) is your client_id.
  4. exp (expiry) in the future.
  5. nonce (sent in the authorize request) matches.

Use a library (jose, oidc-client-ts); don’t hand-roll JWT verification.

Refresh tokens — how do they fit?

Lifetimes, storage options and their trade-offs are covered once, in Access and Refresh Tokens. The browser-specific rule: refresh tokens never go in localStorage.

Refresh token rotation: each refresh issues a new refresh token and invalidates the old one; a replayed old token revokes the family. Auth0 and Okta support it but configure it per application (Okta’s reuse grace period defaults to 30 s, adjustable 0-60; Auth0’s reuse interval is off by default), so turn it on. Design and limits: Refresh Token Rotation.

Single Sign-On (SSO).

OIDC enables SSO across apps using the same Identity Provider:

  1. User logs into App A via Google → has session with Google.
  2. User visits App B; App B redirects to Google; Google sees existing session → no prompt; redirects back with code.
  3. App B has the user logged in without a fresh password entry.

Same flow, “remembered session at the IdP.” Why workplace SSO (Okta, Azure AD, Auth0) is convenient — one login, many apps.

Common OAuth mistakes.

  • Implicit flow for new code — deprecated.
  • Storing refresh tokens in localStorage — XSS reads forever.
  • Skipping state — CSRF on the redirect.
  • Not validating ID token signature — accepting any JWT.
  • redirect_uri not strict — open redirect lets attacker steal codes.
  • client_secret in SPA code — there are no secrets in a SPA; whatever you ship, attackers see.
  • Token validation only on exp — must check signature + issuer + audience.

Gotchas

  • PKCE on a confidential client (server) is RECOMMENDED by RFC 9700 (an OIDC confidential client may rely on nonce instead); for public clients it is a MUST.
  • response_mode=form_post — code POSTed instead of in URL — useful for some SPA setups to keep it out of browser history.
  • Implicit flow may still be used by some legacy IdPs; if you encounter one, push for Auth Code support.
  • OAuth scopes are opaque to the protocol — what read:user means is per-provider. Check docs.
  • prompt=none for silent re-auth in a hidden iframe — fails in Safari and Firefox, which block or partition third-party cookies. (Chrome kept them: Google dropped the phase-out in 2024-2025.) Use refresh tokens or a BFF.
  • Logout — OIDC has a end_session_endpoint. Logging out of your app doesn’t always log out of the IdP — handle deliberately.
  • access_token lifespan vs refresh_token — typical 1h access, 30d refresh. Configurable.

Further reading

Interview angle 6

  • “Which OAuth flow for an SPA?” - authorization code with PKCE. Implicit is deprecated: it returned the access token in the URL fragment, which lands in browser history and can leak through referrers and any script on the page.
  • “What does OIDC add?” - identity. An ID token, which is a JWT of claims about the user, alongside the access token. Validate its signature, iss, aud, exp and the nonce you sent - and remember it is for your client, not for calling your API.
  • “What is the current best practice for token handling?” - the backend-for-frontend pattern: the tokens never reach the browser at all. A small server-side component completes the flow, holds the tokens, and gives the browser an ordinary HttpOnly session cookie. That removes the XSS-exfiltration vector entirely rather than shrinking it.
  • “state or PKCE - which do you need?” - both, for different threats. state binds the callback to the session you started, which is CSRF on the redirect. PKCE proves the party redeeming the code is the party that requested it, which is code interception. Neither substitutes for the other.
  • “What must the identity provider enforce?” - exact redirect_uri matching against a registered allowlist. A wildcard or prefix match combined with any open redirect on your domain means the authorization code is delivered to the attacker, and then PKCE is the only thing left.
  • “Why does refresh token rotation matter?” - because a reused refresh token is evidence of theft. Issuing a new one on every refresh and revoking the whole family on reuse turns a silent compromise into a detected one.