Add login to a Next.js app with OIDC
Wire OpenID Connect authorization code flow with PKCE into the Next.js App Router, with httpOnly session cookies and no client-side token storage.
- A registered OIDC client (client_id, client_secret) at any compliant provider
- Next.js 15 App Router
- A redirect URI such as http://localhost:3000/api/auth/callback registered with the provider
The goal is a login that never exposes tokens to the browser. The browser only ever holds an opaque, httpOnly session cookie. All token handling happens in route handlers on the server. This is the pattern OAuth 2.1 recommends for confidential web apps (see OAuth 2.1 and the authorization code with PKCE recipe).
1. Discover the provider's endpoints
Every OpenID Connect provider publishes a discovery document at /.well-known/openid-configuration. Read it once at startup rather than hardcoding URLs.
// lib/oidc.ts
export interface OidcConfig {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
issuer: string;
userinfo_endpoint: string;
}
let cached: OidcConfig | null = null;
export async function discover(): Promise<OidcConfig> {
if (cached) return cached;
const issuer = process.env.OIDC_ISSUER!; // e.g. https://id.example.com
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
cached = (await res.json()) as OidcConfig;
return cached;
}
2. Build the authorization request
PKCE protects the flow against authorization code interception. Generate a code_verifier, derive its SHA-256 code_challenge, and stash the verifier plus a state value in a short-lived cookie so the callback can validate them.
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { discover } from '@/lib/oidc';
function base64url(buf: ArrayBuffer): string {
return Buffer.from(buf).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function GET(req: NextRequest) {
const cfg = await discover();
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
const challenge = base64url(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)),
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
const url = new URL(cfg.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.OIDC_CLIENT_ID!);
url.searchParams.set('redirect_uri', process.env.OIDC_REDIRECT_URI!);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
const res = NextResponse.redirect(url.toString());
const opts = { httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/', maxAge: 600 };
res.cookies.set('pkce_verifier', verifier, opts);
res.cookies.set('oidc_state', state, opts);
return res;
}
3. Handle the callback and exchange the code
The provider redirects back with code and state. Reject any mismatched state (this defends against CSRF on the redirect). Then exchange the code for tokens at the token endpoint, server to server, sending the code_verifier.
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { discover } from '@/lib/oidc';
import { createSession } from '@/lib/session';
export async function GET(req: NextRequest) {
const cfg = await discover();
const code = req.nextUrl.searchParams.get('code');
const state = req.nextUrl.searchParams.get('state');
const verifier = req.cookies.get('pkce_verifier')?.value;
const expected = req.cookies.get('oidc_state')?.value;
if (!code || !state || state !== expected || !verifier) {
return NextResponse.json({ error: 'invalid_request' }, { status: 400 });
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.OIDC_REDIRECT_URI!,
client_id: process.env.OIDC_CLIENT_ID!,
client_secret: process.env.OIDC_CLIENT_SECRET!,
code_verifier: verifier,
});
const tokenRes = await fetch(cfg.token_endpoint, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
});
if (!tokenRes.ok) return NextResponse.json({ error: 'token_exchange_failed' }, { status: 401 });
const tokens = await tokenRes.json(); // { id_token, access_token, expires_in, ... }
// Validate the id_token signature and claims before trusting it.
// See /recipes/validate-a-jwt/ for the verification step.
const session = await createSession(tokens);
const res = NextResponse.redirect(new URL('/', req.url));
res.cookies.set('session', session, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: tokens.expires_in ?? 3600,
});
res.cookies.delete('pkce_verifier');
res.cookies.delete('oidc_state');
return res;
}
4. Validate the ID token
Never trust an ID token without verifying its signature against the provider's JWKS and checking the iss, aud, and exp claims. That step is its own recipe: validate a JWT. The nonce claim, when you send one in the authorization request, must match what you stored.
5. Read the session in a Server Component
Because the session is a server cookie, any Server Component or route handler can read it without shipping tokens to the client.
// app/page.tsx
import { cookies } from 'next/headers';
import { readSession } from '@/lib/session';
export default async function Home() {
const raw = (await cookies()).get('session')?.value;
const user = raw ? await readSession(raw) : null;
return user
? <p>Signed in as {user.email}</p>
: <a href="/api/auth/login">Sign in</a>;
}
Security checklist
- Tokens stay server-side in an
httpOnly,secure,sameSite=laxcookie. The browser never sees them, which removes the largest XSS token-theft surface. - PKCE (
S256) is used even though this is a confidential client. OAuth 2.1 makes PKCE mandatory for the authorization code flow. stateis validated on the callback to stop CSRF on the redirect.- The ID token is verified before any claim is trusted.
- Logout clears the session cookie and, ideally, calls the provider's
end_session_endpointfor single logout.
For the underlying protocol, see OpenID Connect. For the flow in isolation, see authorization code with PKCE.
Related on Start with Identity
- Blog24,650 exposed server management interfaces leak crackable password hashes before login
Firmware security firm Lava found that 67 percent of 36,872 internet-exposed Baseboard Management Controllers hand over IPMI authentication hashes before a logi
- RecipeAdd passkeys with WebAuthn
Implement passkey registration and authentication with the WebAuthn ceremonies: generate and verify challenges on the server, call navigator.credentials on the
- BlogAgentic AI Identity Is the Next Frontier (And Your IAM Stack Isn't Ready)
AI agents now act on behalf of users, call APIs, and chain tools together. They need identities, scopes, and audit trails, and almost no existing IAM stack was
- BlogCl0p-linked affiliates chain an unauthenticated PTC Windchill RCE with no login step at all
Attackers with tradecraft consistent with Cl0p ransomware affiliates chained a FlexPLM pre-authentication information leak with a critical PTC Windchill RCE (CV
- CVEDrupal Simple OAuth/OIDC auth bypass via an alternate path
Drupal Simple OAuth / OIDC 6.0.0 through 6.0.6 allowed authentication to be skipped on an alternate path. Patched in 6.0.7.
- ArticleIdentity for Gaming: Scale, Social Login, and Child Safety
Identity in gaming has to handle massive spikes, near-zero login friction, cross-platform accounts, anti-cheat and account-takeover defense, and strict child-sa