Skip to content

Web Development · Authentication

OAuth 2.0 and PKCE: The Web Auth Patterns Every SPA Developer Needs in 2026

The implicit flow is dead, and most tutorials still teach it. Here is how authorization code flow with PKCE actually works, how tokens should be stored, and where most SPA auth implementations go wrong.

Anurag Verma

Anurag Verma

9 min read

OAuth 2.0 and PKCE: The Web Auth Patterns Every SPA Developer Needs in 2026

Sponsored

Share

Most authentication bugs in single-page applications don’t come from weak passwords or stolen credentials. They come from the OAuth flow implementation — specifically from using patterns that were deprecated years ago or storing tokens in places attackers can reach.

The OAuth 2.0 implicit flow — where the access token is returned directly in the URL fragment — was the standard recommendation for SPAs through most of the 2010s. OAuth 2.1 (still in draft but widely adopted) explicitly removes it. Every major identity provider stopped recommending it around 2019. Yet tutorials and Stack Overflow answers keep circulating it.

Here is what actually works in 2026.

Why the Implicit Flow Is Gone

The implicit flow returns tokens in the URL fragment: https://yourapp.com/callback#access_token=eyJ.... This causes several problems:

  • URL fragments get stored in browser history
  • Referrer headers can leak the token to third-party analytics or CDNs
  • Tokens in URLs are visible in server logs if the redirect goes through an intermediate server
  • There is no way to verify the token was issued for your specific client (no client authentication)

Authorization code flow avoids all of this. Instead of returning a token in the URL, the authorization server returns a short-lived, single-use code. Your app exchanges that code for tokens via a back-channel server request. Tokens never appear in the URL.

For SPAs and mobile apps that can’t store a client secret securely, PKCE (Proof Key for Code Exchange) replaces the client secret requirement.

PKCE Explained

PKCE (pronounced “pixy”) is an extension to authorization code flow. It proves to the authorization server that the app requesting a token is the same app that initiated the auth request. Without it, an attacker who intercepts the authorization code could exchange it for tokens. With PKCE, they can’t — because they don’t have the code verifier.

The flow:

  1. Generate a random code_verifier (43-128 characters, high-entropy random string)
  2. Hash it with SHA-256 to produce a code_challenge
  3. Send code_challenge and code_challenge_method=S256 in the authorization request
  4. After auth, send code_verifier in the token request
  5. Authorization server hashes the verifier and compares it to the stored challenge. If they match, the code is legitimate

An attacker who intercepts the authorization code at step 4 still needs the code_verifier to exchange it. The verifier never travels over the network during the auth request — only its hash does.

Generating PKCE Values

// Generate a secure code verifier
function generateCodeVerifier(): string {
  const array = new Uint8Array(32)
  crypto.getRandomValues(array)
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

// Hash it to create the code challenge
async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder()
  const data = encoder.encode(verifier)
  const hash = await crypto.subtle.digest('SHA-256', data)
  return btoa(String.fromCharCode(...new Uint8Array(hash)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

// Usage
const verifier = generateCodeVerifier()
const challenge = await generateCodeChallenge(verifier)

// Store verifier — you need it when exchanging the code
sessionStorage.setItem('pkce_verifier', verifier)

The crypto.subtle API is available in all modern browsers and Node.js 15+. The base64url encoding (using - and _ instead of + and /, no padding) is required by the PKCE spec.

The Full Authorization Flow

Step 1: Build the authorization URL

const params = new URLSearchParams({
  response_type: 'code',
  client_id: 'your-client-id',
  redirect_uri: 'https://yourapp.com/callback',
  scope: 'openid profile email',
  state: generateState(), // Random string, store it, compare after redirect
  code_challenge: challenge,
  code_challenge_method: 'S256',
})

window.location.href = `https://auth.example.com/authorize?${params}`

The state parameter prevents CSRF. Generate a random value, store it in sessionStorage, and verify it matches when you receive the redirect.

Step 2: Handle the callback

// In your /callback route
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const returnedState = urlParams.get('state')

// Verify state
const storedState = sessionStorage.getItem('oauth_state')
if (returnedState !== storedState) {
  throw new Error('State mismatch — possible CSRF attack')
}

// Exchange code for tokens
const verifier = sessionStorage.getItem('pkce_verifier')
const tokens = await exchangeCodeForTokens(code, verifier)

Step 3: Exchange the code

async function exchangeCodeForTokens(code: string, verifier: string) {
  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'your-client-id',
      redirect_uri: 'https://yourapp.com/callback',
      code,
      code_verifier: verifier,
    }),
  })

  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`)
  }

  return response.json()
  // Returns: { access_token, refresh_token, id_token, expires_in, token_type }
}

Clean up the PKCE values after a successful exchange — they are single-use.

Token Storage

This is where most implementations make their second major mistake.

localStorage is the wrong default. Any JavaScript running on your page — including third-party scripts, browser extensions, and XSS payloads — can read localStorage. Access tokens in localStorage are accessible to any script with the same origin.

In-memory storage is the right default for access tokens. Store the access token in a JavaScript variable (or in application state like Zustand or a context). It’s gone when the tab closes. It’s not accessible to other scripts. The downside: users must re-authenticate when they close the tab and return.

HttpOnly cookies for refresh tokens, served from your own backend. HttpOnly cookies cannot be read by JavaScript. Your backend issues the cookie, the browser sends it automatically on requests to your domain, and JavaScript never touches the refresh token. This is the pattern that survives XSS attacks.

Access token:  in-memory JavaScript variable
Refresh token: HttpOnly, Secure, SameSite=Strict cookie via your backend

If you need persistence across tab closures without HttpOnly cookies, sessionStorage is marginally better than localStorage — it doesn’t persist across tabs, which limits the blast radius of an XSS attack.

Refresh Token Rotation

Refresh tokens should rotate on every use. When your app exchanges a refresh token for a new access token, the authorization server issues a new refresh token and invalidates the old one.

This limits the damage from a stolen refresh token. If the token is used once by an attacker, subsequent use of the stolen token fails (because the rotation already happened). The server detects a reuse attempt (old token presented after rotation) and can revoke the entire token family.

Most modern identity providers implement rotation by default (Auth0, Cognito, Okta, Google Identity). If you are running your own authorization server, verify it’s enabled.

JWT vs Opaque Tokens

Your authorization server returns either JWT (JSON Web Tokens) or opaque tokens. The choice matters for how your backend validates requests.

JWTs are self-contained. They contain claims (user ID, email, roles, expiry) signed by the authorization server. Your backend validates the signature using the server’s public key without making a network call. This is fast and works well in distributed systems where multiple services need to validate tokens.

The tradeoff: JWTs can’t be revoked before they expire without additional infrastructure (a revocation list). If an access token with a 1-hour expiry is stolen, it’s valid until it expires. Keeping access token lifetime short (5-15 minutes) limits this window.

Opaque tokens are random strings. Your backend calls the authorization server’s introspection endpoint to validate them. One network call per validation, but you can revoke them instantly.

// JWT validation on the backend (Node.js)
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'

const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
})

async function validateToken(token: string) {
  const decoded = jwt.decode(token, { complete: true })
  const key = await client.getSigningKey(decoded?.header.kid)

  return jwt.verify(token, key.getPublicKey(), {
    algorithms: ['RS256'],
    audience: 'your-api-audience',
    issuer: 'https://auth.example.com/',
  })
}

For most APIs: JWTs with short expiry + refresh token rotation is the practical default. The validation speed advantage of JWTs matters in high-throughput services.

Using a Library

Unless you are building an identity provider or have specific requirements, use a battle-tested library rather than implementing this yourself.

For SPAs: Auth.js (formerly NextAuth, now framework-agnostic), oidc-client-ts, or the SDK from your identity provider (Auth0, Cognito, etc.).

// oidc-client-ts example
import { UserManager, WebStorageStateStore } from 'oidc-client-ts'

const userManager = new UserManager({
  authority: 'https://auth.example.com',
  client_id: 'your-client-id',
  redirect_uri: 'https://yourapp.com/callback',
  response_type: 'code',
  scope: 'openid profile email',
  // Handles PKCE automatically
  userStore: new WebStorageStateStore({ store: window.sessionStorage }),
})

// Initiates the flow
await userManager.signinRedirect()

// In the callback route
const user = await userManager.signinRedirectCallback()
// user.access_token is now available in memory

oidc-client-ts handles PKCE, state verification, and token storage. The specific storage mechanism it uses (sessionStorage by default) is configurable. For the access token in memory approach, you can store it in your application state after retrieval.

What OAuth 2.0 Doesn’t Cover

OAuth 2.0 is an authorization framework, not an authentication protocol. It tells you what a user is allowed to do, not who they are. OpenID Connect (OIDC) adds authentication on top: the id_token contains user identity information verified by the authorization server.

If you need to know who the user is (name, email, profile), use OIDC. If you only need to authorize API access, OAuth 2.0 is enough. In practice, most flows use both.

The openid scope in your authorization request triggers OIDC. The returned id_token is a JWT containing user claims. Validate it the same way as an access token.

The Practical Checklist

Before shipping auth:

  • Authorization code flow with PKCE, not implicit
  • state parameter verified on callback
  • Access tokens in memory, not localStorage
  • Refresh tokens in HttpOnly cookies via backend, or in sessionStorage if cookies aren’t feasible
  • Refresh token rotation enabled at the authorization server
  • Short access token lifetime (5-15 minutes)
  • Token validation on the backend on every protected request
  • HTTPS everywhere (including your redirect URIs)

The implicit flow showing up in an old tutorial or a Stack Overflow answer from 2017 is still the implicit flow. Check what your implementation is doing before assuming it’s correct.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored