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.