Implementing PKCE in Next.js from scratch

The OAuth 2.0 implicit flow — where you get a token directly in the URL fragment — was officially deprecated in RFC 9700. If you're still using it, stop. The replacement is the Authorization Code flow with PKCE, and it's what every modern auth integration should use.

PKCE stands for Proof Key for Code Exchange. It was originally designed to protect native apps that can't keep a client secret (any secret in a mobile binary is not a secret), but it's now recommended for all public clients — including SPAs and Next.js apps.

The attack PKCE prevents

In a standard Authorization Code flow without PKCE, the flow is:

  1. App redirects to /authorize?client_id=...&code=...
  2. User authenticates
  3. Auth server redirects back to your app with an authorization code: /callback?code=abc123
  4. App POSTs the code to /token to exchange it for an access token

The problem: on mobile, multiple apps can register the same redirect URI scheme. A malicious app can intercept step 3 — it sees the code in the redirect. It can then call /token itself and get the access token. The code was meant for you; now the attacker has it.

PKCE breaks this. Even if the attacker gets the code, they can't exchange it without the original secret that only your app knows.

How PKCE works

Before redirecting to the authorization server, you generate a random secret called the code verifier. You hash it to create the code challenge. You send the challenge (not the secret) with the authorization request.

When you later exchange the code for a token, you send the original code verifier. The auth server hashes it and compares it to the challenge it stored. If they match, the exchange is valid. An attacker who intercepted the code doesn't have the verifier — they can't complete the exchange.

// Step 1: Generate a cryptographically random code verifier
const array = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = btoa(String.fromCharCode(...array))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

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

const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in sessionStorage — needed at callback time
sessionStorage.setItem('pkce_verifier', codeVerifier);
The base64url encoding (using - and _ instead of + and /, and stripping padding =) is required by RFC 7636. Most implementations get this wrong and wonder why the server rejects the challenge.

Building the authorization URL

const params = new URLSearchParams({
  response_type: 'code',
  client_id: process.env.NEXT_PUBLIC_CLIENT_ID!,
  redirect_uri: `${window.location.origin}/callback`,
  scope: 'openid email profile',
  state: crypto.randomUUID(), // CSRF protection
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
});

// Also store state for CSRF verification at callback
sessionStorage.setItem('oauth_state', params.get('state')!);

window.location.href = `https://app.bastionary.com/api/oidc/authorize?${params}`;

Handling the callback in Next.js App Router

Create app/callback/page.tsx:

'use client'
import { useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'

export default function Callback() {
  const router = useRouter()
  const params = useSearchParams()

  useEffect(() => {
    const code = params.get('code')
    const state = params.get('state')
    const storedState = sessionStorage.getItem('oauth_state')
    const codeVerifier = sessionStorage.getItem('pkce_verifier')

    // Always verify state to prevent CSRF
    if (!code || state !== storedState) {
      router.replace('/login?error=invalid_state')
      return
    }

    exchangeCode(code, codeVerifier!).then(tokens => {
      sessionStorage.removeItem('pkce_verifier')
      sessionStorage.removeItem('oauth_state')
      // Store tokens and redirect
      sessionStorage.setItem('access_token', tokens.access_token)
      router.replace('/dashboard')
    }).catch(() => {
      router.replace('/login?error=exchange_failed')
    })
  }, [])

  return <div>Signing you in…</div>
}

async function exchangeCode(code: string, codeVerifier: string) {
  const resp = await fetch('https://app.bastionary.com/api/oidc/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: `${window.location.origin}/callback`,
      client_id: process.env.NEXT_PUBLIC_CLIENT_ID!,
      code_verifier: codeVerifier,  // The original secret, NOT the hash
    }),
  })
  if (!resp.ok) throw new Error('Token exchange failed')
  return resp.json()
}
Never store access tokens in localStorage. It's accessible by any JavaScript on the page, including injected scripts. sessionStorage is slightly better (tab-isolated) but still accessible from JS. For the highest security, use an HttpOnly cookie via a server route — the browser won't expose it to JavaScript at all.

Using Bastionary's built-in PKCE support

If you're using Bastionary as your auth backend, this is handled automatically. The /api/oidc/authorize endpoint accepts code_challenge and code_challenge_method=S256, stores the challenge, and verifies it at /api/oidc/token. You can also use Bastionary's managed login page — redirect to app.bastionary.com/app/{your-slug}/login — and skip the PKCE implementation entirely.

Summary

  • Never use the implicit flow — it's deprecated and exposes tokens in URLs
  • Always use PKCE for public clients (SPAs, mobile, CLIs)
  • The code verifier is the secret; the code challenge is the hash you send to the server
  • Verify the state parameter on callback to prevent CSRF
  • Store tokens in HttpOnly cookies when possible, not localStorage