Skip to content

Web Development · Authentication

OAuth 2.0 and PKCE: What SPAs Actually Need

The implicit flow is dead and tutorials still teach it. How authorization code flow with PKCE works, where tokens belong, and where SPA auth goes wrong.

Anurag Verma

Anurag Verma

8 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.

Frequently asked questions

Why is the implicit flow deprecated?
Because it puts the access token in the URL, and URLs go places you do not control. Fragments persist in browser history, referrer headers can carry them to analytics and CDN endpoints, and any intermediate redirect through a server writes them to a log. There is also no client authentication, so nothing proves the token was issued for your application. Authorization code flow avoids all four by returning a code that is useless without a second, authenticated exchange.
What does PKCE protect against, exactly?
Authorization code interception. Without it, anyone who captures the code in transit or via a malicious app registered on the same redirect URI can exchange it for tokens, because the exchange requires no proof of identity from a public client. PKCE adds that proof: your app generates a random verifier, sends only its SHA-256 hash during the authorization request, and presents the verifier at exchange time. An attacker with the code has the hash at most, and cannot reverse it.
Where should I store the access token?
In memory, in a JavaScript variable or your application state. It disappears when the tab closes, and no other script can read it, which is the property that matters because localStorage is readable by every script running on your origin, including any third-party tag and any XSS payload. The cost is that users re-authenticate after closing the tab, which is what refresh tokens in HttpOnly cookies are for.
Isn't sessionStorage good enough?
It is marginally better than localStorage and still not good. It does not persist across tabs, which limits how far a stolen token travels, but it remains readable by any JavaScript on the page, so an XSS payload takes it just as easily. Treat it as a slightly smaller blast radius rather than a defence. Memory for access tokens and HttpOnly cookies for refresh tokens is the combination that actually survives a script injection.
Why rotate refresh tokens?
Because rotation turns a silent theft into a detectable event. Each exchange issues a new refresh token and invalidates the old one, so if an attacker uses a stolen token, the legitimate client's next attempt presents a token the server already retired. That reuse is a signal nothing else gives you, and the standard response is to revoke the entire token family, logging out both parties. Without rotation, a stolen refresh token works quietly until it expires.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored