EponwebPractical guides to web development and technology
Web Security

Stopping Token Leakage with PKCE in Next.js

How we eliminated high-severity vulnerabilities by migrating to OAuth 2.0 Authorization Code Flow with PKCE.

Lucas Ferreira
Lucas FerreiraLead Frontend Engineer8 min read
Editorial image illustrating Stopping Token Leakage with PKCE in Next.js

In November 2025, our external penetration tester, Sarah from Securify, dropped a vulnerability report on my desk that made my stomach drop. We were two weeks away from the public launch of a new fintech dashboard built on Next.js 15. The report flagged a "High Severity" issue related to our authentication flow. We had been using the OAuth 2.0 Implicit Flow—a standard choice for Single Page Applications (SPAs) years ago—but it has become a liability in web-security.

The problem wasn't that we were exposing passwords; we were using a reputable Identity Provider (IdP). The issue was the mechanics of the token return. In the Implicit Flow, the access token is returned in the URL fragment after a user authenticates. Sarah showed me how a malicious script injected into the page—perhaps via a compromised third-party dependency—could easily read window.location.hash and exfiltrate the token. Since the token is the key to the castle, once it's stolen, the game is over.

We needed to migrate to the Authorization Code Flow with Proof Key for Code Exchange (PKCE). This flow ensures the token is never exposed to the browser front-end; instead, the browser exchanges a temporary code for the token. However, public clients, like browser-based apps, cannot store a client secret securely. PKCE solves this by creating a dynamic, one-time secret for every session. Here is the gritty, code-heavy story of how we ripped out the old flow and built a bulletproof PKCE implementation.

The Vulnerability in Plain Sight

Implicit Flow was designed for a time when browsers couldn't efficiently make cross-origin POST requests. It works by sending the access token directly in the redirect URL. While modern browsers have improved, the fundamental architecture of Implicit Flow remains insecure because the access token is visible to the client-side JavaScript environment.

Our specific scenario involved a "User Info" endpoint that returned sensitive financial data. If an attacker managed to perform a cross-site scripting (XSS) attack, they wouldn't just steal the user's session cookie; they would have the actual bearer token, allowing them to make API calls from their own servers until the token expired. The audit report estimated that this could have allowed data exfiltration affecting roughly 15% of our beta user base before the anomaly detection systems would trigger.

Photographic detail related to Stopping Token Leakage with PKCE in Next.js

Engineering the PKCE Solution

The Authorization Code Flow with PKCE works by creating a cryptographically random "code verifier" on the client. We then transform this verifier into a "code challenge" (usually by hashing it with SHA-256). The app sends the challenge to the authorization server. When the server redirects back with a code, the app sends that code back to the server along with the original verifier. If the verifier transforms into the challenge sent earlier, the server knows the request is legitimate.

The tricky part in Next.js is managing state between the client and the server-side route handlers, specifically preserving the verifier generated on the client so it can be used later during the token exchange. You cannot rely on localStorage for the duration of the auth handshake due to potential storage partitioning issues in strict browser environments, and passing it in the URL is insecure.

Generating the Verifier and Challenge

I started by creating a utility function using the native Web Crypto API. As of 2026, browser support for window.crypto.subtle is universal across Chrome, Firefox, Safari, and Edge, so we don't need external polyfills.

We generate a 32-byte random string for the verifier. For the challenge, we need to hash it and then Base64URL encode it.

// utils/crypto.ts
function generateRandomString(length: number): string {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  let result = '';
  const values = new Uint8Array(length);
  window.crypto.getRandomValues(values);
  for (let i = 0; i < length; i++) {
    result += charset[values[i] % charset.length];
  }
  return result;
}

async function sha256(plain: string): Promise<ArrayBuffer> {
  const encoder = new TextEncoder();
  const data = encoder.encode(plain);
  return window.crypto.subtle.digest('SHA-256', data);
}

function base64UrlEncode(buffer: ArrayBuffer): string {
  let str = '';
  const bytes = new Uint8Array(buffer);
  const len = bytes.byteLength;
  for (let i = 0; i < len; i++) {
    str += String.fromCharCode(bytes[i]);
  }
  return btoa(str)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

export async function generatePKCEPair(): Promise<{ verifier: string; challenge: string }> {
  const verifier = generateRandomString(64);
  const hashed = await sha256(verifier);
  const challenge = base64UrlEncode(hashed);
  return { verifier, challenge };
}

In our Next.js App Router, we call this function on the client side when the user clicks "Login." We store the verifier in memory (or a secure, httpOnly cookie if we anticipate a long state gap, but for a standard redirect, a state object in a Context or a transient memory store works best to avoid XSS surface area).

The Server-Side Token Exchange

This is where the security magic happens. We moved the token exchange logic to a Next.js Route Handler (app/api/auth/callback/route.ts). This ensures that the client credentials (Client Secret) are never exposed to the browser, and the network traffic is server-to-server.

The browser receives the code from the IdP redirect. It sends this code and the verifier to our Next.js backend.

// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get('code');
  const verifier = searchParams.get('verifier'); // Passed from client callback
  const state = searchParams.get('state');

  if (!code || !verifier) {
    return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
  }

  // Configuration from environment variables
  const clientId = process.env.OAUTH_CLIENT_ID;
  const clientSecret = process.env.OAUTH_CLIENT_SECRET;
  const redirectUri = `${process.env.APP_URL}/api/auth/callback`;
  const tokenEndpoint = 'https://id-provider.example.com/oauth/token';

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: redirectUri,
    client_id: clientId,
    client_secret: clientSecret,
    code_verifier: verifier,
  });

  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: body.toString(),
  });

  if (!response.ok) {
    const errorText = await response.text();
    console.error('Token exchange failed:', errorText);
    return NextResponse.redirect(new URL('/login?error=auth_failed', request.url));
  }

  const tokens = await response.json();

  // Set HttpOnly cookies. 
  // This is crucial: XSS cannot read these cookies.
  const cookieOptions = {
    httpOnly: true,
    secure: true,
    sameSite: 'lax' as const,
    path: '/',
    maxAge: 60 * 60 * 24 * 7, // 1 week
  };

  const redirectResponse = NextResponse.redirect(new URL('/dashboard', request.url));
  redirectResponse.cookies.set('access_token', tokens.access_token, cookieOptions);
  if (tokens.refresh_token) {
    redirectResponse.cookies.set('refresh_token', tokens.refresh_token, cookieOptions);
  }

  return redirectResponse;
}

By handling the fetch request on the server, we completely eliminate the risk of the access token sitting in the browser's URL history or JavaScript memory. The token travels directly from the IdP to our Next.js server, and then into a secure HttpOnly cookie.

Handling the State Parameter

One specific vulnerability we had to guard against was Cross-Site Request Forgery (CSRF). While PKCE prevents code interception, it doesn't inherently prevent a malicious site from initiating the auth flow and tricking the user into logging in, binding the victim's account to the attacker's session.

To mitigate this, we implemented a state parameter. Before redirecting to the login page, we generate a random state string, store it (mapped to the session), and send it to the IdP. When the IdP redirects back, the state must match.

In our implementation, we combined the state check with the PKCE storage. We created a transient session object in a Redis instance during the auth flow, storing both the verifier and the state. When the callback hits the server, we validate the state against Redis before proceeding with the token exchange.

Performance and Accessibility Impacts

Adding this extra hop—the browser calling the server to exchange the token—does add a measurable latency. I benchmarked the new flow against the old Implicit Flow. The "time-to-interactive" on the dashboard increased by roughly 180ms on average. This is the cost of the server-side POST request to the token endpoint.

However, the perceived performance remained acceptable because we utilized the Next.js loading states and a skeleton screen while the authentication handshake was processing. From a performance budget perspective, we are trading 180ms for the elimination of a critical security flaw—a trade-off any responsible engineer should make.

On the accessibility front, redirects can be disorienting for users relying on screen readers. We ensured that our login page explicitly announces the redirection behavior. We added an aria-live region to the login interface that announces "Connecting to secure login server..." when the flow initiates, and "Authentication successful, redirecting to dashboard..." upon the callback. This ensures users are not left in a void wondering if the button click registered.

browser Support and Compatibility

We successfully deployed this to production in January 2026. The Web Crypto API functions used (window.crypto.getRandomValues and window.crypto.subtle.digest) are supported in all modern browsers used by our target audience (Chrome 120+, Safari 17+, Firefox 125+).

For the rare edge case of a legacy browser—likely a corporate desktop restricted by policy—we added a feature detection snippet. If crypto.subtle is undefined, we fall back to a standard Authorization Code flow (without PKCE), but we enforce a re-authentication check every 5 minutes to minimize the window of exposure. This fallback accounts for less than 0.1% of our traffic but ensures we don't lock out enterprise users on restricted hardware.

The Verdict

Migrating to PKCE transformed our authentication architecture from a "good enough" standard to a robust, enterprise-grade security posture. We closed the high-severity finding from the audit. The implicit flow is now effectively dead for us. The complexity of managing the verifier state and the server-side exchange code is a small price to pay for the assurance that our users' tokens are never exposed to the hostile environment of the client-side DOM.

If you are still handling tokens in JavaScript variables or URL fragments, you are sitting on a ticking time bomb. The shift to server-side token exchange via PKCE in Next.js is not just a security upgrade; it is a necessary evolution of how we build trust on the web. The initial refactoring took about three days, but the peace of mind—and the passing audit score—was worth every line of code.