Integrating OIDC With Next.js App Router

Scenario: you have a Next.js App Router app and need real OIDC login without leaking tokens into the client bundle. This walkthrough is part of the Integrating OIDC With Web Frameworks guide, and it places every credential-bearing step on the server so no access or refresh token ever reaches a client component.

The App Router gives you exactly the right primitives: Route Handlers for /login and /callback, middleware for route protection, and Server Components that can read the session directly. The trap is the React boundary — anything passed as a prop to a client component is serialized into the HTML payload and visible in the browser. Tokens must stay on the server side of that line.

Root Cause: The Client/Server Boundary

Next.js App Router renders Server Components on the server and streams the result to the browser. A Server Component can hold a token safely. The moment you pass that token to a "use client" component as a prop, it is embedded in the RSC payload and readable in devtools — the same exposure as localStorage, which the authorization code flow with PKCE (RFC 7636) exists to prevent. The fix is structural: keep tokens in an encrypted, HttpOnly cookie session, read them only in Server Components and Route Handlers, and pass derived, non-sensitive data (a username, a boolean) across the boundary.

Setup: Encrypted Session with iron-session

iron-session seals session data into a stateless, signed-and-encrypted HttpOnly cookie. Define the session shape and a helper.

// lib/session.ts
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";

export interface AppSession {
  sub?: string;
  access_token?: string;
  refresh_token?: string;
  expires_at?: number;
  // Transient values used only between /login and /callback:
  code_verifier?: string;
  state?: string;
  nonce?: string;
}

export const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!, // 32+ char secret, NOT the OAuth client_secret
  cookieName: "app_session",
  cookieOptions: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax", // REQUIRED: survives the top-level redirect back from the IdP
    path: "/",
  },
};

export async function getSession() {
  return getIronSession<AppSession>(await cookies(), sessionOptions);
}

SameSite=Lax is mandatory here. A Strict cookie is not sent on the cross-site GET the IdP uses to return the user, so the callback would lose its code_verifier and state. See configuring secure cookie flags in production for the full cookie-flag rationale.

The /login Route Handler

Generate PKCE, state, and nonce; store them in the session; redirect to the IdP.

// app/auth/login/route.ts
import * as oauth from "oauth4webapi";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { as, client } from "@/lib/oidc"; // discovery + client config

export async function GET() {
  const code_verifier = oauth.generateRandomCodeVerifier();
  const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier);
  const state = oauth.generateRandomState();
  const nonce = oauth.generateRandomNonce();

  const session = await getSession();
  session.code_verifier = code_verifier;
  session.state = state;
  session.nonce = nonce;
  await session.save();

  const url = new URL(as.authorization_endpoint!);
  url.searchParams.set("client_id", client.client_id);
  url.searchParams.set("redirect_uri", process.env.OIDC_REDIRECT_URI!);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "openid profile email offline_access");
  url.searchParams.set("code_challenge", code_challenge);
  url.searchParams.set("code_challenge_method", "S256");
  url.searchParams.set("state", state);
  url.searchParams.set("nonce", nonce);

  redirect(url.toString());
}

The /callback Route Handler

Validate state, exchange the code with the original verifier, verify the ID Token under a strict algorithm allowlist, then persist only the tokens server-side.

// app/auth/callback/route.ts
import * as oauth from "oauth4webapi";
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { as, client, clientAuth } from "@/lib/oidc";

export async function GET(request: NextRequest) {
  const session = await getSession();
  const { code_verifier, state, nonce } = session;
  if (!code_verifier || !state || !nonce) {
    return NextResponse.json({ error: "no_login_in_progress" }, { status: 400 });
  }

  // 1. CSRF defense: state from the callback must equal the issued state (RFC 9700).
  const params = oauth.validateAuthResponse(as, client, new URL(request.url), state);

  // 2. Token exchange bound to the PKCE verifier (RFC 7636).
  const tokenRes = await oauth.authorizationCodeGrantRequest(
    as, client, clientAuth, params, process.env.OIDC_REDIRECT_URI!, code_verifier,
  );

  // 3. Verify ID Token: signature via JWKS, nonce, and an explicit alg allowlist.
  //    oauth4webapi rejects alg:none/HS256 for asymmetric verification by default.
  const result = await oauth.processAuthorizationCodeResponse(as, client, tokenRes, {
    expectedNonce: nonce,
    requireIdToken: true,
  });
  const claims = oauth.getValidatedIdTokenClaims(result)!;

  // 4. Persist tokens server-side; clear the transient login values.
  session.sub = claims.sub;
  session.access_token = result.access_token;
  session.refresh_token = result.refresh_token;
  session.expires_at = Date.now() + (result.expires_in ?? 300) * 1000;
  session.code_verifier = undefined;
  session.state = undefined;
  session.nonce = undefined;
  await session.save();

  return NextResponse.redirect(new URL("/", request.url));
}

The OIDC client config (as, client, clientAuth) pins the signing algorithm — supply RS256 or ES256 to match your IdP, and never permit HS256 or alg: none, which enable algorithm-confusion attacks against the public-key verification.

Middleware: Protecting Routes

Use middleware.ts to gate authenticated routes. Middleware runs on the edge and can read the encrypted cookie to decide whether to redirect to /auth/login.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { sessionOptions, AppSession } from "@/lib/session";

export async function middleware(request: NextRequest) {
  const res = NextResponse.next();
  const session = await getIronSession<AppSession>(request, res, sessionOptions);
  if (!session.sub) {
    const login = new URL("/auth/login", request.url);
    return NextResponse.redirect(login);
  }
  return res;
}

export const config = { matcher: ["/dashboard/:path*", "/account/:path*"] };

Accessing Tokens in Server Components

A Server Component reads the session directly. It may render user data, but it must pass only non-sensitive values across the client boundary.

// app/dashboard/page.tsx  (Server Component — no "use client")
import { getSession } from "@/lib/session";
import { ensureFreshAccessToken } from "@/lib/refresh";
import { ProfileCard } from "./profile-card"; // a client component

export default async function Dashboard() {
  const session = await getSession();
  const accessToken = await ensureFreshAccessToken(session); // refreshes if near expiry

  const res = await fetch("https://api.example.com/me", {
    headers: { Authorization: `Bearer ${accessToken}` },
    cache: "no-store",
  });
  const profile = await res.json();

  // Pass ONLY display data to the client component — never the token.
  return <ProfileCard name={profile.name} email={profile.email} />;
}

ensureFreshAccessToken runs the silent refresh server-side, rotating the stored refresh token per secure token refresh and rotation patterns. The client component receives name and email, not the bearer token.

Security Implications

  • No token crosses the React boundary. Tokens live only in the encrypted cookie, read in Server Components and Route Handlers. Passing one as a prop to a "use client" component embeds it in the RSC payload — treat that as equivalent to writing it to localStorage.
  • state and nonce are non-negotiable. Both are stored in the session at /login and validated at /callback, satisfying the CSRF and replay defenses of RFC 9700 and OpenID Connect Core.
  • Refresh stays invisible. Silent refresh on the server means no prompt=none iframe and no token exposure in the page.

Prevention & Monitoring

  • Log sub, iss, request ID, and outcome on every /callback — alert on repeated state mismatches, which signal login-CSRF probing.
  • Alert on invalid_grant spikes at the token endpoint; they often indicate verifier/cookie loss from a SameSite misconfiguration.
  • Add a CI assertion that no token-shaped string appears in the client bundle (grep the .next output for eyJ JWT prefixes).

Frequently Asked Questions

Can I use NextAuth.js / Auth.js instead of wiring this manually?

Yes, and for most apps you should — Auth.js implements the same PKCE + state + encrypted-cookie model under the hood. Build it by hand when you need precise control over token storage, custom session shapes, a non-standard IdP, or to run inside an existing BFF. The security invariants are identical either way: tokens server-side, state/nonce validated, RS256/ES256 only.

Why iron-session instead of a database session?

iron-session is stateless — the encrypted cookie is the session, so it works on the edge with no datastore. The trade-off is revocation: you cannot instantly kill a stateless cookie session before it expires. If you need immediate logout, store sessions in Redis keyed by an opaque sid cookie instead, and delete the record on logout.

Can middleware perform the token refresh?

It can read the session, but the edge middleware runtime is constrained and re-saving a rotated refresh token from middleware is fragile under concurrent requests. Prefer refreshing in the Server Component or Route Handler that actually calls the API, where you control the write and can serialize it. Use middleware only to check session.sub and redirect unauthenticated users.

The callback fails with "no_login_in_progress" intermittently — why?

The session cookie carrying code_verifier/state is not arriving at /callback. The usual cause is SameSite=Strict, which is not sent on the cross-site redirect back from the IdP. Set sameSite: "lax". Also confirm secure: true only in production over HTTPS, since a Secure cookie is dropped over plain HTTP in local dev.