The first time I added "Login with Google" to a SPA, I copied a snippet from a blog post, read the access_token straight out of the URL fragment, stuffed it into localStorage, and called my API with... the ID token, because "it's a JWT too". Everything worked. It took me years to understand that the snippet was wrong at almost every step. OAuth 2.0 and OpenID Connect aren't hard because of cryptography; they're hard because there are so many parameters that look alike but protect completely different things. This is how I do OAuth/OIDC for web apps as of late 2026: authorization code + PKCE one request at a time, what state and nonce actually stop, how the three kinds of token differ, why the implicit flow is dead, and the Backend-for-Frontend (BFF) pattern that keeps tokens on the server — with Next.js code, refresh rotation, logout, and the vulnerabilities I've run into in the wild.

Who's who: four OAuth roles and two OIDC names#

Most OAuth confusion starts with not being clear about who is talking to whom. There are four roles:

  • Resource owner: the user — the person who owns the data and grants consent.
  • Client: the application that wants to access data on the user's behalf. Here, that's your web app.
  • Authorization server (AS): where the user signs in and consents, and where tokens are issued. Keycloak, Auth0, Okta, Microsoft Entra ID, Zitadel, or Google.
  • Resource server (RS): the API that holds the data, receives access tokens and decides yes or no.

OAuth 2.0 is fundamentally an authorization protocol: the client obtains an access token to call an API. It says nothing about who the user is. OpenID Connect (OIDC) is the authentication layer on top: it adds the openid scope, the ID token, the userinfo endpoint and discovery. In OIDC terminology the AS is an OpenID Provider (OP) and the client is a Relying Party (RP). Same things, two names.

One more distinction matters just as much: confidential versus public clients.

  • A confidential client can keep a secret — a server with a client_secret or private key to authenticate itself to the AS.
  • A public client can't — a SPA running in the browser, a mobile app. Everything in your JavaScript bundle is public.

This whole post revolves around one idea: a pure SPA is a public client, and the best way to protect it is to turn it into a confidential client with a small backend in the middle. That's the BFF.

Authorization code + PKCE, one request at a time#

This is the only flow I use for web apps in 2026. I'll walk through the actual requests, because when you debug, these are exactly the requests you'll be staring at in the Network tab or your proxy logs.

Authorization code + PKCE through a BFF. The browser only ever sees the code (single-use, useless without the code_verifier) and an HttpOnly session cookie; every token lives on the server.

Step 1: the client prepares three random values#

Before sending the user to the AS, the client generates:

  • code_verifier: a high-entropy random string (43–128 characters). Kept secret by the client.
  • state: a random string that ties the callback to the browser session that started the login.
  • nonce: a random string the AS will embed in the ID token.

From the code_verifier the client computes code_challenge = BASE64URL(SHA256(code_verifier)):

TypeScript
// lib/auth/pkce.ts — to understand the mechanism; use a library in practice
import { createHash, randomBytes } from "node:crypto";

export function createPkcePair() {
  const verifier = randomBytes(32).toString("base64url"); // 43 characters
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

Always use the S256 method. The plain method (challenge equals verifier) only exists for environments without SHA-256, which is not your environment.

Step 2: redirect to the authorization endpoint#

HTTP
GET /authorize?response_type=code
  &client_id=dicode-web
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback
  &scope=openid%20profile%20email%20offline_access%20orders%3Aread
  &state=af0ifjsldkj3Kq9
  &nonce=n-0S6_WzA2Mj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: id.example.com

(Wrapped for readability; in reality it's one URL.) Note that only the code_challenge travels through the browser — the code_verifier never does. offline_access is the OIDC scope for requesting a refresh token; many providers require it, some use their own setting.

Step 3: the AS redirects back with a code#

The user signs in (password, passkey, MFA — that's the AS's job), consents, and the AS responds:

HTTP
HTTP/1.1 302 Found
Location: https://app.example.com/api/auth/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj3Kq9&iss=https%3A%2F%2Fid.example.com

The code is single-use and very short-lived (typically under a minute). The iss parameter comes from RFC 9207 — the AS tells the client who issued this code, which defends against mix-up attacks (more below). The client must check that state matches what it stored before doing anything else.

Step 4: exchange the code for tokens over the back channel#

This is a server-to-server request; it doesn't go through the browser:

HTTP
POST /oauth/token HTTP/1.1
Host: id.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGljb2RlLXdlYjpzM2NyM3Q=

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The AS recomputes SHA256(code_verifier), compares it to the code_challenge from step 2, checks that redirect_uri is identical, authenticates the client, and returns:

JSON
{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "8xLOxBtZp8.Qm9oLWdyZWVu...",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMTAifQ...",
  "scope": "openid profile email offline_access orders:read"
}

With a Cache-Control: no-store header. The client now holds three tokens, each with its own job — we'll pull them apart shortly.

What PKCE actually stops#

PKCE (RFC 7636) was originally designed for mobile apps: on a phone, a malicious app can register the same custom URL scheme and intercept the code on its way back. Without PKCE, an intercepted code can be exchanged for tokens. With PKCE, the interceptor doesn't have the code_verifier, so the code is worthless.

But for web apps PKCE stops something more important: authorization code injection. An attacker obtains a victim's code (from a log, a Referer header, an open redirect) and injects it into the callback of their own session, so the client redeems it and logs the attacker into the victim's account. With PKCE, the code is bound to the code_verifier of the session that created it — the attacker's session has a different verifier, so the exchange fails.

That's why RFC 9700 (the OAuth 2.0 Security Best Current Practice, published January 2025) recommends PKCE for every client, including confidential clients with a client_secret. OAuth 2.1 (still an IETF draft, but stable in substance) makes PKCE mandatory for the authorization code flow.

state, nonce and PKCE: three parameters, three threats#

These three random values look alike, so they're often treated as interchangeable. They aren't:

ParameterChecked byChecked whereStops
stateClientCallback, against a value bound to the browser sessionCSRF on the callback: the attacker forces the victim's browser to complete a login the attacker started (login CSRF)
nonceClientThe nonce claim inside the ID tokenAn ID token being replayed or injected into another session
code_verifierASToken endpoint, against the code_challengeA code being intercepted or injected into another session
iss (response)ClientCallback, against the AS the client choseMix-up attacks when the client talks to several ASes

A few practical notes:

  • state must be bound to the browser, not just "exist in Redis". If you only check that the state is in your store, an attacker starts a flow, gets a valid state of their own, and sends the victim the callback link. I bind it with a short-lived cookie holding the state (or a transaction id), and the callback requires the cookie to match the query.
  • RFC 9700 allows relying on PKCE for CSRF protection if the client is sure the AS supports PKCE. I keep state anyway because it's cheap and I use it as the key for the transaction (verifier, nonce, returnTo).
  • A transaction is single-use. Use GETDEL in Redis or delete the cookie as soon as the callback is processed, so the same callback URL can't be replayed.

Transaction cookies and SameSite

The callback is a top-level navigation from the AS's domain to your app. A SameSite=Strict cookie is not sent with that cross-site navigation, so the callback always sees "missing state". The transaction cookie needs SameSite=Lax. If the AS uses response_mode=form_post (a POST back to the callback), even Lax isn't enough, and that one cookie needs SameSite=None; Secure.

ID token, access token, refresh token: don't mix up the roles#

Three tokens, three different audiences. This is the table I draw on the whiteboard whenever I onboard someone:

ID tokenAccess tokenRefresh token
Meant forThe client (RP)The resource server (API)The authorization server
audYour client_idThe API identifier, e.g. https://api.example.comNot applicable — usually opaque
FormatAlways a signed JWTJWT (RFC 9068) or opaque, depending on the ASOpaque to the client
Answers"Who just signed in, when, and how?""What is this request allowed to do?""Give me a new access token"
Should the client read it?Yes — and must validate itNo — treat it as a black boxNo
Sent to the API?NeverYes, in AuthorizationNever

A decoded ID token looks like this:

JSON
{
  "iss": "https://id.example.com",
  "sub": "248289761001",
  "aud": "dicode-web",
  "exp": 1792000000,
  "iat": 1791999700,
  "auth_time": 1791999690,
  "nonce": "n-0S6_WzA2Mj",
  "sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
  "amr": ["pwd", "mfa"],
  "email": "di@example.com"
}

The client must validate: the signature against a key from the AS's JWKS, iss matching exactly, aud containing its own client_id, exp not passed, and nonce matching the transaction. A decent OIDC library does all of this — the trouble usually starts when someone skips the library and does JSON.parse(atob(...)). I go deep on JWT validation in a separate post, JWT pitfalls and key rotation.

Why a SPA must not call APIs with the ID token#

This is the most common bug I see when reviewing SPAs. The reason isn't just "the spec says so":

  1. Wrong audience. The ID token's aud is the frontend's client_id. An API that accepts it is accepting tokens issued to a different application. If another app in the same tenant can get an ID token with the same aud (say, apps sharing a client), it can call your API. A textbook confused deputy.
  2. No scopes. An ID token says "this is Di", not "Di allowed this app to read orders". The API loses any ability to authorize by scope and consent.
  3. Different lifecycle. An ID token describes a sign-in event; it isn't designed to be refreshed, revoked or bound to a sender.
  4. Needless PII. ID tokens often carry email and name — sending them with every API request means they land in the logs of every proxy along the way.

If your API needs to know who the user is, the access token already has sub. If it needs more, the API calls userinfo or looks up its own database.

Identify users by iss + sub, not email

sub is only unique within one issuer, and email can change or be unverified. The "nOAuth" class of bugs disclosed in 2023 came from exactly this: apps looked users up by the email claim from a multi-tenant provider, where users could edit that email themselves. The foreign key of your user table should be the pair (iss, sub).

The implicit flow is dead, and so is ROPC#

Back in 2015, SPAs used the implicit flow (response_type=token): the AS returned the access token directly in the URL fragment, with no code exchange. The reason then was that browsers didn't support CORS well enough to call the token endpoint. That reason disappeared long ago; the problems stayed:

  • The token lives in the URL. The fragment goes into browser history, every script on the page can read it, and an open redirect that preserves the fragment can carry it to another domain.
  • No way to bind the token to the client. No PKCE, no client authentication, no way to detect injected tokens.
  • No refresh tokens, so SPAs used hidden iframes with prompt=none to renew — which browsers' third-party cookie restrictions have since broken.

RFC 9700 is explicit: the implicit grant SHOULD NOT be used, and the Resource Owner Password Credentials grant (ROPC — the client collects username and password itself) MUST NOT be used. OAuth 2.1 removes both from the spec entirely.

ApproachWhere tokens liveWhat XSS getsNotes
Implicit flowURL fragment, then JSThe access token, until it expiresRemoved
Code + PKCE inside the SPAJS memory / Web WorkerAccess and refresh tokens, or runs a fresh flow itselfAcceptable, but XSS is still very costly
Code + PKCE via a BFFServer (Redis/DB)Only the ability to use the session while the tab is openMy default

The second row needs a word. The IETF OAuth working group's "OAuth 2.0 for Browser-Based Applications" document points out that however you hide tokens — in a Web Worker, in a closure — an XSS payload can simply start a brand-new authorization flow in a hidden iframe, receive a code and exchange it for fresh tokens, because it runs in your origin with the right client_id and redirect_uri. No token-hiding trick beats an attacker sitting inside your origin. The only thing that works is not having the tokens there.

Backend-for-Frontend: tokens stay on the server#

The BFF idea is simple: a small backend on the same origin as the SPA acts as a confidential client. It runs the OAuth flow, holds all the tokens, and gives the browser an ordinary HttpOnly session cookie. Every API call from the SPA goes through the BFF, which attaches the access token and forwards it.

What you get:

  • Tokens never touch JavaScript. XSS is still dangerous (it can send requests through the BFF while the tab is open), but it can't take a token away and use it elsewhere for days.
  • The client authenticates to the AS with a secret or private_key_jwt, so the AS can issue longer-lived refresh tokens with more confidence.
  • Refresh, rotation and logout all live in one place on the server, easy to observe and fix.

The cost: one extra network hop, a session store (Redis), and the BFF becomes a component you operate. With Next.js the BFF is just route handlers in the same app, so the cost is lower than you'd think.

Configuring the client with openid-client#

I use Filip Skokan's openid-client v6 — OpenID-certified, a functional API, runs on Node and edge runtimes.

lib/auth/oidc.ts
import * as client from "openid-client";

let configPromise: Promise<client.Configuration> | undefined;

export function oidcConfig(): Promise<client.Configuration> {
  // One discovery per process; reset on failure so the next call retries
  configPromise ??= client
    .discovery(
      new URL(process.env.OIDC_ISSUER!),
      process.env.OIDC_CLIENT_ID!,
      process.env.OIDC_CLIENT_SECRET!,
    )
    .catch((err) => {
      configPromise = undefined;
      throw err;
    });
  return configPromise;
}

// Always built from config, never from request.url (behind a proxy that's an internal URL)
export const REDIRECT_URI = `${process.env.APP_ORIGIN}/api/auth/callback`;
export const SCOPES = "openid profile email offline_access orders:read";

export const cookieBase = {
  httpOnly: true,
  secure: true,
  path: "/",
  sameSite: "lax" as const,
};

A session store in Redis#

lib/auth/session-store.ts
import { randomBytes } from "node:crypto";
import Redis from "ioredis";

export const redis = new Redis(process.env.REDIS_URL!);

export type Session = {
  iss: string;
  sub: string;
  sid?: string; // the IdP's sid — needed for back-channel logout
  idToken: string;
  accessToken: string;
  refreshToken?: string;
  expiresAt: number; // access token expiry, epoch ms
};

const ABSOLUTE_TTL_S = 60 * 60 * 12;

export async function createSession(data: Session): Promise<string> {
  const id = randomBytes(32).toString("base64url");
  const tx = redis.multi().set(`sess:${id}`, JSON.stringify(data), "EX", ABSOLUTE_TTL_S);
  // Reverse index sid -> BFF sessions, so an IdP-initiated logout can find them
  if (data.sid) tx.sadd(`idp-sid:${data.sid}`, id).expire(`idp-sid:${data.sid}`, ABSOLUTE_TTL_S);
  await tx.exec();
  return id;
}

export async function readSession(id: string): Promise<Session | null> {
  const raw = await redis.get(`sess:${id}`);
  return raw ? (JSON.parse(raw) as Session) : null;
}

export async function writeSession(id: string, data: Session) {
  await redis.set(`sess:${id}`, JSON.stringify(data), "KEEPTTL");
}

export async function destroySession(id: string) {
  await redis.del(`sess:${id}`);
}

The session id is 256 random bits; the cookie holds only that id. If you'd rather not depend on Redis you can encrypt the whole session into the cookie (iron-session style), but refresh token rotation gets awkward because an old cookie can be replayed — I prefer a server-side store.

The login route#

app/api/auth/login/route.ts
import { NextResponse, type NextRequest } from "next/server";
import * as client from "openid-client";
import { cookieBase, oidcConfig, REDIRECT_URI, SCOPES } from "@/lib/auth/oidc";
import { redis } from "@/lib/auth/session-store";
import { safeReturnTo } from "@/lib/auth/return-to";

export async function GET(request: NextRequest) {
  const config = await oidcConfig();

  const codeVerifier = client.randomPKCECodeVerifier();
  const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
  const state = client.randomState();
  const nonce = client.randomNonce();
  const returnTo = safeReturnTo(request.nextUrl.searchParams.get("returnTo"));

  // The transaction lives 10 minutes, keyed by state, usable exactly once
  await redis.set(
    `authtx:${state}`,
    JSON.stringify({ codeVerifier, nonce, returnTo, issuer: config.serverMetadata().issuer }),
    "EX",
    600,
  );

  const authorizeUrl = client.buildAuthorizationUrl(config, {
    redirect_uri: REDIRECT_URI,
    scope: SCOPES,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    state,
    nonce,
  });

  const res = NextResponse.redirect(authorizeUrl);
  // Bind the transaction to this browser — Lax so it rides along with the navigation back from the AS
  res.cookies.set("__Host-authtx", state, { ...cookieBase, maxAge: 600 });
  return res;
}

safeReturnTo matters more than it looks — more on that in the vulnerabilities section:

lib/auth/return-to.ts
export function safeReturnTo(input: string | null): string {
  // Same-origin relative paths only; blocks //evil.com and /\evil.com
  if (!input || !input.startsWith("/") || input.startsWith("//") || input.includes("\\")) {
    return "/";
  }
  const url = new URL(input, process.env.APP_ORIGIN);
  return url.origin === process.env.APP_ORIGIN ? url.pathname + url.search : "/";
}

The callback route#

app/api/auth/callback/route.ts
import { NextResponse, type NextRequest } from "next/server";
import * as client from "openid-client";
import { cookieBase, oidcConfig, REDIRECT_URI } from "@/lib/auth/oidc";
import { createSession, redis } from "@/lib/auth/session-store";
import { logger } from "@/lib/logger";

type AuthTx = { codeVerifier: string; nonce: string; returnTo: string; issuer: string };

export async function GET(request: NextRequest) {
  const state = request.nextUrl.searchParams.get("state");
  const boundState = request.cookies.get("__Host-authtx")?.value;
  if (!state || !boundState || state !== boundState) {
    return new Response("Invalid login state", { status: 400 });
  }

  // GETDEL: the transaction is consumed immediately, the callback can't be replayed
  const raw = await redis.getdel(`authtx:${state}`);
  if (!raw) return new Response("Login expired, please try again", { status: 400 });
  const tx = JSON.parse(raw) as AuthTx;

  const config = await oidcConfig();
  // Rebuild from the configured REDIRECT_URI: the redirect_uri sent to /token must be identical
  const callbackUrl = new URL(REDIRECT_URI + request.nextUrl.search);

  let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
  try {
    tokens = await client.authorizationCodeGrant(config, callbackUrl, {
      pkceCodeVerifier: tx.codeVerifier,
      expectedState: state,
      expectedNonce: tx.nonce,
      idTokenExpected: true,
    });
  } catch (err) {
    // Never log callbackUrl: it contains the code
    logger.warn({ reason: err instanceof Error ? err.message : "unknown" }, "oidc callback failed");
    return new Response("Sign-in failed", { status: 400 });
  }

  const claims = tokens.claims()!;
  const sessionId = await createSession({
    iss: claims.iss,
    sub: claims.sub,
    sid: typeof claims.sid === "string" ? claims.sid : undefined,
    idToken: tokens.id_token!,
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + (tokens.expires_in ?? 300) * 1000,
  });

  // 303 to a clean URL: the URL with the code doesn't stay in the address bar or history
  const res = NextResponse.redirect(new URL(tx.returnTo, process.env.APP_ORIGIN), 303);
  // Deleting a __Host- cookie needs Secure + Path=/ too, or the browser ignores the Set-Cookie
  res.cookies.set("__Host-authtx", "", { ...cookieBase, maxAge: 0 });
  res.cookies.set("__Host-sid", sessionId, { ...cookieBase, maxAge: 60 * 60 * 12 });
  res.headers.set("Referrer-Policy", "no-referrer");
  res.headers.set("Cache-Control", "no-store");
  return res;
}

authorizationCodeGrant checks state, sends the code_verifier, validates the ID token (signature, iss, aud, exp, nonce) and checks the iss response parameter when the AS advertises RFC 9207 support. That's exactly why I don't hand-roll this part.

The API proxy: where it's easy to build an open proxy#

app/api/bff/[...path]/route.ts
import type { NextRequest } from "next/server";
import { getFreshSession } from "@/lib/auth/session";

const API_ORIGIN = process.env.API_ORIGIN!;
const ALLOWED_ROOTS = new Set(["orders", "invoices", "profile"]);

async function proxy(request: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
  // CSRF: state-changing requests must come from our own origin
  if (request.method !== "GET" && request.headers.get("sec-fetch-site") !== "same-origin") {
    return Response.json({ error: "forbidden" }, { status: 403 });
  }

  const { path } = await ctx.params;
  // Block SSRF: only known resources, no "..", no empty segments
  if (!ALLOWED_ROOTS.has(path[0]) || path.some((p) => p === ".." || p === "." || p === "")) {
    return Response.json({ error: "not_found" }, { status: 404 });
  }

  const sid = request.cookies.get("__Host-sid")?.value;
  const session = sid ? await getFreshSession(sid) : null;
  if (!session) return Response.json({ error: "unauthenticated" }, { status: 401 });

  const target = new URL(`/${path.map(encodeURIComponent).join("/")}`, API_ORIGIN);
  target.search = request.nextUrl.search;

  const upstream = await fetch(target, {
    method: request.method,
    headers: {
      authorization: `Bearer ${session.accessToken}`,
      accept: "application/json",
      "content-type": request.headers.get("content-type") ?? "application/json",
    },
    body: request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer(),
    cache: "no-store",
    signal: AbortSignal.timeout(10_000),
  });

  // Don't forward browser cookies to the API, and don't pass the API's Set-Cookie back
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "content-type": upstream.headers.get("content-type") ?? "application/json",
      "cache-control": "no-store",
    },
  });
}

export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };

Three deliberate decisions here: an allow-list for paths (otherwise the BFF becomes an SSRF tool that comes with an access token attached), a Sec-Fetch-Site check on state-changing requests (the session cookie rides along automatically, so CSRF is back on the table — see the frontend security post), and never forwarding browser headers wholesale to the API.

The NestJS version#

If your BFF is a separate NestJS service (say the frontend is a Vite SPA), the pieces are identical: login/callback controllers using openid-client, a guard that loads the session, and a proxy controller. On NestJS 11 with Express 5, wildcard routes must be named:

bff/bff-proxy.controller.ts
import { All, Controller, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { BffSessionGuard, type SessionRequest } from "./bff-session.guard";
import { ApiProxyService } from "./api-proxy.service";

@Controller("bff")
@UseGuards(BffSessionGuard) // reads __Host-sid, refreshes if needed, sets req.bffSession
export class BffProxyController {
  constructor(private readonly api: ApiProxyService) {}

  @All("*path")
  async forward(@Req() req: Request & SessionRequest, @Res() res: Response) {
    const upstream = await this.api.forward(req, req.bffSession.accessToken);
    res.status(upstream.status).type(upstream.headers.get("content-type") ?? "json");
    res.setHeader("Cache-Control", "no-store");
    res.send(Buffer.from(await upstream.arrayBuffer()));
  }
}

I covered guards and session organization in NestJS in detail in NestJS auth with passkeys and sessions; the BFF only differs in that its sessions hold the IdP's tokens instead of issuing its own.

Refresh tokens: rotation, reuse detection and race conditions#

On the authorization server side#

Refresh tokens live long, which makes them juicy targets. RFC 9700 requires refresh tokens for public clients to be either sender-constrained (DPoP, mTLS) or rotated: each use returns a new refresh token and invalidates the old one. If an old one shows up again, the AS can't tell the real user from the thief — so it revokes the entire family descended from that login. If you run your own AS or manage refresh tokens yourself, I walk through families and hash storage in the NestJS auth post.

With a BFF the refresh token lives on your server, on a confidential client — many providers let you turn rotation off in that case. I keep it on, because a leaked Redis dump then reveals itself on the very next refresh.

On the BFF side: two requests refreshing at once#

This is a production bug I actually hit: a dashboard fired six parallel requests right as the access token expired. All six saw an expired token, all six called refresh with the same refresh token. The first succeeded and got a new token; the other five used the old one — the AS treated that as reuse, revoked the whole family, and the user was bounced to the login screen mid-task. Users reported "the app logs me out randomly", and the AS logs said "refresh token reuse detected" as if we were under attack.

The fix is single-flight per session:

lib/auth/session.ts
import { setTimeout as sleep } from "node:timers/promises";
import * as client from "openid-client";
import { oidcConfig } from "./oidc";
import { destroySession, readSession, redis, writeSession, type Session } from "./session-store";

const SKEW_MS = 30_000; // refresh 30 seconds early so tokens don't expire in flight

const isFresh = (s: Session) => s.expiresAt - SKEW_MS > Date.now();

export async function getFreshSession(id: string): Promise<Session | null> {
  const session = await readSession(id);
  if (!session) return null;
  if (isFresh(session)) return session;
  if (!session.refreshToken) return null;

  const lockKey = `lock:refresh:${id}`;
  const acquired = await redis.set(lockKey, "1", "PX", 10_000, "NX");

  if (!acquired) {
    // Another request is refreshing: wait for it, then re-read
    for (let i = 0; i < 25; i++) {
      await sleep(200);
      const latest = await readSession(id);
      if (!latest) return null;
      if (isFresh(latest)) return latest;
    }
    return null;
  }

  try {
    const tokens = await client.refreshTokenGrant(await oidcConfig(), session.refreshToken);
    const next: Session = {
      ...session,
      accessToken: tokens.access_token,
      // With rotation there's always a new one; without it, keep the old one
      refreshToken: tokens.refresh_token ?? session.refreshToken,
      expiresAt: Date.now() + (tokens.expires_in ?? 300) * 1000,
    };
    await writeSession(id, next);
    return next;
  } catch (err) {
    if (err instanceof client.ResponseBodyError && err.error === "invalid_grant") {
      // Refresh token expired or revoked: this session is over
      await destroySession(id);
      return null;
    }
    throw err;
  } finally {
    await redis.del(lockKey);
  }
}

The lock lives in Redis rather than process memory because BFFs usually run several instances. Many commercial providers also offer a "grace period" or "reuse interval" of a few seconds during which the old refresh token is still accepted — useful as a safety net, but don't rely on it instead of single-flight.

Note

A network failure mid-refresh is the hardest case. If the AS rotated but the response got lost on the way back, the BFF still holds the old refresh token — and the next use counts as "reuse". There's no perfect answer; the AS's grace period helps most here. It's also why I set timeouts and retries carefully on the refresh request itself: retrying immediately with the same token is usually safe within the grace period, retrying a minute later isn't.

Redirect URIs: exact matching, no wildcards#

The AS only sends codes to a registered redirect_uri. If matching is loose, codes go elsewhere. Configurations I've seen:

  • Wildcard subdomains: https://*.example.com/callback. One taken-over subdomain (a DNS record pointing at a cancelled service, a preview deployment of a PR from a fork) and it receives codes.
  • Prefix matching: you register https://app.example.com/ and the AS accepts anything starting with it. The attacker uses https://app.example.com/redirect?to=https://evil.example — combined with an open redirect in your app, the code follows along to the attacker's domain.
  • Ignoring the query string: the AS only compares the path, and the attacker adds parameters that change what the callback page does.

RFC 9700 requires exact string matching, with the single exception of the port on loopback addresses for native apps. OAuth 2.1 says the same. In practice: each environment (local, staging, production) registers its own URI, preview deployments use a separate client or no real login, and never enable "allow wildcards" in your IdP console just because it's convenient.

Logout: harder than you think#

When a user clicks "Sign out", at least three sessions are alive:

  1. The BFF session — the __Host-sid cookie and its Redis record.
  2. The SSO session at the IdP — the IdP's cookie on its own domain. Leave it alive and the next "Sign in" click goes straight through without asking anything.
  3. Sessions in other apps sharing the same IdP.

Deleting your own cookie only handles (1). Here's what I do to cover all three.

RP-Initiated Logout#

OpenID Connect RP-Initiated Logout 1.0 defines the end_session_endpoint (advertised in discovery). The BFF deletes its session, revokes the refresh token, then sends the user to the IdP:

app/api/auth/logout/route.ts
import { NextResponse, type NextRequest } from "next/server";
import * as client from "openid-client";
import { cookieBase, oidcConfig } from "@/lib/auth/oidc";
import { destroySession, readSession } from "@/lib/auth/session-store";

export async function POST(request: NextRequest) {
  if (request.headers.get("sec-fetch-site") !== "same-origin") {
    return new Response(null, { status: 403 }); // block logout CSRF
  }

  const config = await oidcConfig();
  const sid = request.cookies.get("__Host-sid")?.value;
  let next = new URL("/signed-out", process.env.APP_ORIGIN);

  if (sid) {
    const session = await readSession(sid);
    await destroySession(sid);
    if (session?.refreshToken) {
      // RFC 7009 — best effort, don't block logout if the IdP is slow
      await client.tokenRevocation(config, session.refreshToken).catch(() => undefined);
    }
    if (session && config.serverMetadata().end_session_endpoint) {
      next = client.buildEndSessionUrl(config, {
        id_token_hint: session.idToken,
        post_logout_redirect_uri: `${process.env.APP_ORIGIN}/signed-out`,
      });
    }
  }

  const res = NextResponse.redirect(next, 303);
  res.cookies.set("__Host-sid", "", { ...cookieBase, maxAge: 0 });
  return res;
}

Logout is a POST from a plain form (not fetch), so the browser actually navigates to the IdP. The post_logout_redirect_uri must also be pre-registered and exactly matched, just like the redirect URI.

Back-Channel Logout#

When the user signs out of another app, or an admin locks the account at the IdP, you want the BFF session to die too. OpenID Connect Back-Channel Logout 1.0 lets the IdP call your server directly with a logout_token (a JWT):

app/api/auth/backchannel-logout/route.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
import { redis } from "@/lib/auth/session-store";

const jwks = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI!));
const BCL_EVENT = "http://schemas.openid.net/event/backchannel-logout";

export async function POST(request: Request) {
  const form = await request.formData();
  const logoutToken = form.get("logout_token");
  if (typeof logoutToken !== "string") return new Response(null, { status: 400 });

  try {
    const { payload } = await jwtVerify(logoutToken, jwks, {
      issuer: process.env.OIDC_ISSUER,
      audience: process.env.OIDC_CLIENT_ID,
      algorithms: ["RS256", "ES256"],
      maxTokenAge: "2m",
    });

    const events = payload.events as Record<string, unknown> | undefined;
    if (!events || !(BCL_EVENT in events)) throw new Error("not a logout token");
    if ("nonce" in payload) throw new Error("logout token must not contain nonce");
    if (!payload.sid && !payload.sub) throw new Error("missing sid and sub");

    // Replay protection: each jti is processed once
    const fresh = await redis.set(`bcl-jti:${payload.jti}`, "1", "EX", 600, "NX");
    if (!fresh) return new Response(null, { status: 200, headers: { "Cache-Control": "no-store" } });

    if (typeof payload.sid === "string") {
      const ids = await redis.smembers(`idp-sid:${payload.sid}`);
      if (ids.length) await redis.del(...ids.map((id) => `sess:${id}`), `idp-sid:${payload.sid}`);
    }
    // sub only: delete every session for the user (needs a sub -> sessions index too)

    return new Response(null, { status: 200, headers: { "Cache-Control": "no-store" } });
  } catch {
    return new Response(null, { status: 400, headers: { "Cache-Control": "no-store" } });
  }
}

That's why createSession above maintains the idp-sid index. This endpoint must be reachable from the IdP (watch out for firewalls blocking inbound traffic), and it doesn't need cookies or a CSRF check — its authenticity comes from the logout token's signature.

As for front-channel logout (the IdP embeds an iframe per app to clear cookies), I avoid it: browsers' third-party cookie restrictions mean that iframe often doesn't carry your app's cookies, so it fails silently.

DPoP: when a bearer token isn't enough#

A bearer token is exactly what it sounds like: whoever holds it can use it. DPoP (RFC 9449, Demonstrating Proof of Possession) binds the token to a client key pair:

  1. The client creates a key pair (in the browser, a non-extractable WebCrypto key).
  2. Every request to the token endpoint and the API carries a DPoP header — a small JWT signed with the private key containing the method (htm), URL (htu), time (iat), a jti and a hash of the access token (ath).
  3. The AS issues a token with a cnf.jkt claim holding the public key's thumbprint. The API rejects the token unless the proof is signed by that key.
HTTP
GET /orders HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIn19...

A token leaked through a log or a proxy is useless without the private key. But be honest about the limits: in a SPA, XSS can't extract a non-extractable key, but it can still call the signing function while the tab is open. DPoP narrows the damage in much the same way a BFF does; it doesn't replace one. Where I find DPoP most valuable is between the BFF and the API, or between services, where you want tokens leaked into logs to be unusable — or mTLS-bound tokens (RFC 8705) if your infrastructure already does mTLS.

Vulnerabilities I've seen in the wild#

An open redirect turned code leak#

The setup: a loosely registered redirect URI, and an app endpoint /go?url=... that redirects anywhere. The attacker builds an authorize link with redirect_uri=https://app.example.com/go?url=https://evil.example. The AS accepts it because the prefix matches, the code arrives at /go, and the browser carries it on to evil.example (in the query or via Referer). No AS vulnerability required — just two "convenient" configurations meeting each other.

Defense: exact redirect URIs at the AS, and any returnTo/next in the app accepts only relative paths, like safeReturnTo above. I check the post-logout returnTo too.

Mix-up attacks#

When a client supports several ASes ("Login with A", "Login with B") and one of them is malicious or compromised, the attacker can make the client send a code from the honest AS to the malicious AS's token endpoint — because the callback alone doesn't tell the client where the code came from. Defense: store the expected issuer in the transaction (like the issuer field in the login route) and check the iss response parameter (RFC 9207), or use a distinct redirect URI per AS. With a single IdP the risk is small — but it appears the moment someone adds "Login with GitHub".

Code injection#

As covered in the PKCE section: a leaked code (from a log, a Referer) gets injected into the attacker's session. PKCE and nonce are the two layers that stop it. I once reviewed a client that "had PKCE" but stored the code_verifier keyed by state in a global store instead of binding it to the browser — meaning anyone holding state + code could complete the flow. Binding the transaction to the browser's cookie is mandatory.

Tokens and codes leaking through Referer and logs#

The "boring" category, and the most common:

  • The callback page loads third-party resources (analytics, fonts, images) while the URL still has ?code=.... The Referer header carries the code away. Fix: the callback renders no HTML; it processes and returns a 303 to a clean URL, with Referrer-Policy: no-referrer.
  • Access logs and APM record full URLs. Nginx, load balancers, Sentry breadcrumbs, the OpenTelemetry url.full span attribute all capture query strings. Scrub code, state and id_token_hint at the logging layer, or disable query logging for the callback route.
  • Tokens in query strings. ?access_token=... when calling an API — RFC 6750 allows it, but every security document advises against it. Never.
  • Logging the token endpoint's full response while debugging, then forgetting to remove it. I've seen refresh tokens live in Elasticsearch for 30 days that way.

Validating nothing at all#

An API that "authenticates" by decoding the JWT and reading sub, skipping the signature check because "the token comes from the IdP". Or checking the signature but not aud, so tokens issued to any app in the same tenant work. Or using a multi-tenant IdP's common endpoint without checking which tenant the iss belongs to. I dig into all of this in JWT pitfalls and key rotation.

Mistakes I keep seeing in code review#

  • Using the ID token as a bearer token for APIs. Send the access token, and let the API check its own aud.
  • Hand-rolling the OAuth flow with fetch. Each step has a small check that's easy to forget. Use a certified library (openid-client, or your IdP's official one).
  • state only checked for "exists". It must be bound to the browser and single-use.
  • redirect_uri built from request.url. Behind a reverse proxy it becomes http://10.0.3.7:3000/..., the token endpoint rejects it, and someone "fixes" it by loosening the AS config.
  • Refresh without single-flight. Random logouts and false reuse-detection alerts.
  • Logout that only clears the local cookie. The next sign-in goes straight through without a prompt — on a shared computer that's a real vulnerability.
  • Requesting scopes "just in case". An access token carries admin:* because one screen needed it once. Scopes are what limit the damage when a token leaks.
  • Tokens in localStorage because "a BFF is too complicated". With Next.js, a BFF is four route handlers.

Further reading#

Summary#

  • Web apps use authorization code + PKCE (S256) for every client; no implicit, no ROPC.
  • state is bound to the browser via a SameSite=Lax cookie and used once; nonce is checked inside the ID token; the iss response parameter is compared with the expected issuer.
  • ID tokens are for the client, access tokens for the API, refresh tokens only go back to the AS. Never send the ID token to an API; identify users by (iss, sub).
  • SPAs go through a BFF: tokens live in Redis, the browser only holds a __Host-sid cookie with HttpOnly; Secure; SameSite=Lax, and the proxy has a path allow-list and a Sec-Fetch-Site check.
  • redirect_uri and post_logout_redirect_uri are matched exactly and built from config, never from request.url; every returnTo accepts relative paths only.
  • Turn on refresh token rotation, and refresh in the BFF with a per-session single-flight lock, early by a skew margin.
  • Logout deletes the BFF session, revokes the refresh token, calls the end_session_endpoint, and accepts back-channel logout by sid.
  • The callback returns a 303 to a clean URL with Referrer-Policy: no-referrer; logs never record the code, tokens or the callback's full URL.
  • Consider DPoP or mTLS for tokens between the BFF and the API when tokens leaking into logs is a real risk.