# Add login to a Next.js app with OIDC

Source: https://startwithidentity.com/recipes/add-login-to-nextjs/
Last updated: 2026-06-19
License: content by Start with Identity. Cite the source URL.

---

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](https://startwithidentity.com/standards/oauth-2-1/) and the [authorization code with PKCE recipe](https://startwithidentity.com/recipes/oidc-authorization-code-pkce/)).

## 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.

```ts
// 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.

```ts
// 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`.

```ts
// 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](https://startwithidentity.com/recipes/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.

```tsx
// 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=lax` cookie. 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.
- `state` is 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_endpoint` for single logout.

For the underlying protocol, see [OpenID Connect](https://startwithidentity.com/standards/openid-connect/). For the flow in isolation, see [authorization code with PKCE](https://startwithidentity.com/recipes/oidc-authorization-code-pkce/).
