Skip to content

Quickstart: Next.js (App Router) ​

A complete, working example of IESF login, API calls and logout in a Next.js App Router application. Here Next.js is the BFF: route handlers and server components talk to the IESF Central Gateway, and the gateway token never reaches the browser or a client component.

Using a separate React SPA with its own backend instead? See Quickstart: React SPA + Express BFF.

PartRunsKnows the gatewayToken?
Client components ('use client')In the browserNo. They only navigate to /auth/* and call /api/*
Route handlers, server componentsNext.js serverYes, read from the session
Session cookie app_sessionBrowser, HttpOnlyHolds the token encrypted (iron-session); JavaScript cannot read it, only the server can decrypt it
Backend API (optional)Your server, private networkReceives it from Next.js per request and checks it with Central

How it works ​

What you need from the IESF admins (see Platform integration):

  • a platformKey, for example MY_APP
  • a redirect pair: return URL https://app.example.com/ → callback https://app.example.com/auth/callback
  • the BFF key of that pair (client ID + secret) for Next.js
  • the backend key of the platform, if you have a separate backend API
bash
npm install iron-session server-only
bash
# .env (server-only: never prefix these with NEXT_PUBLIC_)
CENTRAL_BASE=https://auth.iesf.gg
APP_BASE_URL=https://app.example.com
PLATFORM_KEY=MY_APP
ENVIRONMENT_KEY=PROD
BFF_CLIENT_ID=…
BFF_CLIENT_SECRET=…
SESSION_PASSWORD=at-least-32-random-characters……
BACKEND_URL=http://orders-api.internal:8080

1. Session and Central helpers (server only) ​

ts
// lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';
import { getIronSession, type SessionOptions } from 'iron-session';

export type SessionData = {
  loginPending?: string;
  gatewayToken?: string;
  user?: { id: string; issuer: string; sub: string; permissions: string[] };
};

export const sessionOptions: SessionOptions = {
  cookieName: 'app_session',
  password: process.env.SESSION_PASSWORD!,
  ttl: 12 * 60 * 60, // the gateway session's default lifetime
  cookieOptions: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/' },
};

// Readable everywhere on the server; saving or destroying works only in route handlers and server actions.
export async function getSession() {
  return getIronSession<SessionData>(await cookies(), sessionOptions);
}
ts
// lib/central.ts
import 'server-only';

export const CENTRAL = process.env.CENTRAL_BASE!;
export const APP_BASE_URL = process.env.APP_BASE_URL!;
export const APP_ORIGIN = new URL(APP_BASE_URL).origin;
export const PLATFORM_KEY = process.env.PLATFORM_KEY!;
export const ENVIRONMENT_KEY = process.env.ENVIRONMENT_KEY ?? 'PROD';
export const BFF_BASIC = `Basic ${Buffer.from(`${process.env.BFF_CLIENT_ID}:${process.env.BFF_CLIENT_SECRET}`).toString('base64')}`;

// Calls a Central user API on behalf of the signed-in user.
export function centralFetch(gatewayToken: string, path: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  headers.set('Accept', 'application/json');
  headers.set('Cookie', `iesf_auth_session=${encodeURIComponent(gatewayToken)}`);
  if (init.method && init.method !== 'GET') {
    headers.set('Origin', APP_ORIGIN); // required on Central writes; server-side fetch does not add it
    headers.set('Content-Type', 'application/json');
  }
  return fetch(`${CENTRAL}${path}`, { ...init, headers, cache: 'no-store' });
}

import 'server-only' makes the build fail if a client component ever imports these files, so the token and the secrets cannot be bundled for the browser by accident.

2. Route handlers ​

ts
// app/auth/login/route.ts — login and registration (a full page navigation, never fetch())
import { NextResponse, type NextRequest } from 'next/server';
import { randomBytes } from 'node:crypto';
import { getSession } from '@/lib/session';
import { CENTRAL, PLATFORM_KEY } from '@/lib/central';

export async function GET(request: NextRequest) {
  const wanted = request.nextUrl.searchParams.get('returnTo') ?? '/';
  const returnTo = wanted.startsWith('/') && !wanted.startsWith('//') ? wanted : '/';
  const session = await getSession();
  session.loginPending = randomBytes(16).toString('hex'); // login CSRF guard, checked in the callback
  await session.save();
  const start = new URL('/v1/auth/start', CENTRAL);
  start.searchParams.set('platformKey', PLATFORM_KEY);
  start.searchParams.set('returnTo', returnTo);
  return NextResponse.redirect(start);
}
ts
// app/auth/callback/route.ts — exchange the one-time code (valid ~60 s)
import { NextResponse, type NextRequest } from 'next/server';
import { getSession } from '@/lib/session';
import { APP_BASE_URL, BFF_BASIC, CENTRAL, ENVIRONMENT_KEY, PLATFORM_KEY } from '@/lib/central';

export async function GET(request: NextRequest) {
  const failed = () => NextResponse.redirect(new URL('/?login=failed', APP_BASE_URL));
  const code = request.nextUrl.searchParams.get('code');
  const session = await getSession();
  if (!code?.startsWith('handoff_') || !session.loginPending) return failed();

  const response = await fetch(`${CENTRAL}/v1/auth/exchange`, {
    method: 'POST',
    headers: { Authorization: BFF_BASIC, 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({ code }),
    cache: 'no-store',
  }).catch(() => null);
  if (!response?.ok) return failed();
  const exchange = await response.json();
  if (exchange.platform.key !== PLATFORM_KEY || exchange.environment.key !== ENVIRONMENT_KEY
    || !exchange.session.gatewayToken || Date.parse(exchange.session.expiresAt) <= Date.now()) {
    return failed();
  }

  // The whole cookie value is replaced, so nothing from before the login survives in it.
  session.loginPending = undefined;
  session.gatewayToken = exchange.session.gatewayToken;
  session.user = { id: exchange.user.id, issuer: exchange.user.issuer, sub: exchange.user.sub, permissions: exchange.permissions };
  await session.save();
  return NextResponse.redirect(exchange.returnTo);
}
ts
// app/auth/logout/route.ts — POST only; ends your session, the gateway session and the identity session
import { NextResponse, type NextRequest } from 'next/server';
import { getSession } from '@/lib/session';
import { APP_BASE_URL, APP_ORIGIN, CENTRAL } from '@/lib/central';

export async function POST(request: NextRequest) {
  if (request.headers.get('origin') !== APP_ORIGIN) return NextResponse.json({ error: 'forbidden' }, { status: 403 });
  const session = await getSession();
  const token = session.gatewayToken;
  session.destroy();

  let logoutUrl: string | null = null;
  if (token) {
    const response = await fetch(`${CENTRAL}/v1/auth/logout`, {
      method: 'POST',
      headers: {
        Cookie: `iesf_auth_session=${encodeURIComponent(token)}`,
        Origin: APP_ORIGIN,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ returnTo: `${APP_BASE_URL}/` }),
      cache: 'no-store',
    }).catch(() => null);
    if (response?.ok) logoutUrl = (await response.json()).logoutUrl;
  }
  return NextResponse.json({ redirectTo: logoutUrl ?? '/' });
}

// A link or prefetch must never log anyone out.
export function GET() {
  return NextResponse.json({ error: 'method_not_allowed' }, { status: 405, headers: { Allow: 'POST' } });
}
ts
// app/api/orders/route.ts — client components call this; the token goes to your backend server to server
import { NextResponse, type NextRequest } from 'next/server';
import { getSession } from '@/lib/session';
import { APP_ORIGIN } from '@/lib/central';

async function forward(request: NextRequest) {
  if (request.method !== 'GET' && request.headers.get('origin') !== APP_ORIGIN) {
    return NextResponse.json({ error: 'forbidden' }, { status: 403 }); // CSRF guard for writes
  }
  const session = await getSession();
  if (!session.gatewayToken) return NextResponse.json({ error: 'signed_out' }, { status: 401 });
  const response = await fetch(`${process.env.BACKEND_URL}/orders${request.nextUrl.search}`, {
    method: request.method,
    headers: { 'Content-Type': 'application/json', 'X-Gateway-Token': session.gatewayToken },
    body: request.method === 'GET' ? undefined : await request.text(),
    cache: 'no-store',
  }).catch(() => null);
  if (!response) return NextResponse.json({ error: 'backend_unavailable' }, { status: 503 });
  if (response.status === 401) session.destroy();
  return new NextResponse(response.body, { status: response.status, headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' } });
}

export { forward as GET, forward as POST };

3. Pages and client components ​

Server components read the session and call Central directly; client components only get plain data as props.

tsx
// app/page.tsx — a server component
import { getSession } from '@/lib/session';
import { centralFetch } from '@/lib/central';
import { SignInButton, SignOutButton } from './auth-buttons';

export default async function Home() {
  const session = await getSession();
  if (!session.gatewayToken) return <SignInButton />;

  const response = await centralFetch(session.gatewayToken, '/v1/users/me');
  // Rendering cannot change cookies: a stale session is cleared by the next route handler call.
  if (response.status === 401) return <SignInButton />;
  const me = await response.json();

  // Pass only what the UI needs, never the session or the token.
  return <p>Hello {me.profile.firstName ?? me.localUser.primaryEmail} <SignOutButton /></p>;
}
tsx
// app/auth-buttons.tsx
'use client';

export function SignInButton() {
  // A full page navigation: the identity login cannot run inside fetch() or an iframe.
  const signIn = () => window.location.assign(`/auth/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
  return <button onClick={signIn}>Sign in</button>;
}

export function SignOutButton() {
  async function signOut() {
    const response = await fetch('/auth/logout', { method: 'POST' });
    const { redirectTo } = response.ok ? await response.json() : { redirectTo: '/' };
    window.location.assign(redirectTo); // the identity logout, then back to your site
  }
  return <button onClick={signOut}>Sign out</button>;
}
tsx
// Any client component calling your API: no Authorization header, the session cookie is enough.
'use client';
import { useEffect, useState } from 'react';

export function Orders() {
  const [orders, setOrders] = useState<unknown[] | null>(null);
  useEffect(() => {
    fetch('/api/orders').then(async (response) => {
      if (response.status === 401) return window.location.assign(`/auth/login?returnTo=${encodeURIComponent(window.location.pathname)}`);
      setOrders((await response.json()).orders);
    });
  }, []);
  return <pre>{JSON.stringify(orders, null, 2)}</pre>;
}

4. Backend API (optional) ​

If Next.js calls a separate backend API, that API checks the token it receives in X-Gateway-Token with POST /v1/auth/introspect and the platform's backend key, caching only active results for 30–60 s. The code is the same as in Quickstart: React SPA + Express BFF, step 3. Without a separate backend, skip this step: the encrypted session already proves the user.

Checklist: the token never reaches the browser ​

  • [ ] gatewayToken, the handoff code and the client secrets are only read in route handlers, server components or server actions; lib/session.ts and lib/central.ts import server-only.
  • [ ] No secret or token in a NEXT_PUBLIC_ variable, in client component props, in localStorage, in a URL or in a JSON response.
  • [ ] The session cookie is HttpOnly, Secure, SameSite=Lax, and encrypted with a strong SESSION_PASSWORD (or holds only an ID for a server-side store).
  • [ ] Login and logout are full page navigations; logout is a POST route handler that checks Origin.
  • [ ] The callback requires the login-pending marker set by /auth/login.
  • [ ] Calls to Central send Cookie: iesf_auth_session=<URL-encoded token>, plus Origin on writes, with cache: 'no-store'.
  • [ ] Route handlers that change data check Origin; client components call them without an Authorization header.
  • [ ] A 401 from Central or your backend means "signed out": clear the session in a route handler and send the user to /auth/login. A 5xx means "unavailable": do not sign the user out.

Notes ​

  • Encrypted cookie or server-side store. iron-session keeps the token inside the encrypted cookie, which is simple and stateless. If you prefer the token never to leave your server even encrypted, store it in Redis or your database and keep only a random session ID in the cookie, as the React SPA + Express BFF quickstart does.
  • Middleware. You can add middleware.ts that redirects to /auth/login when the app_session cookie is missing, but decrypting and validating the session belongs in route handlers and server components.
  • Server actions. They run on the server and can use getSession() and centralFetch() like route handlers; Next.js already checks their Origin.

Next: Platform integration checklist · Authentication flow · API reference