Appearance
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.
| Part | Runs | Knows the gatewayToken? |
|---|---|---|
Client components ('use client') | In the browser | No. They only navigate to /auth/* and call /api/* |
| Route handlers, server components | Next.js server | Yes, read from the session |
Session cookie app_session | Browser, HttpOnly | Holds the token encrypted (iron-session); JavaScript cannot read it, only the server can decrypt it |
| Backend API (optional) | Your server, private network | Receives 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 exampleMY_APP - a redirect pair: return URL
https://app.example.com/→ callbackhttps://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-onlybash
# .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:80801. 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.tsandlib/central.tsimportserver-only. - [ ] No secret or token in a
NEXT_PUBLIC_variable, in client component props, inlocalStorage, in a URL or in a JSON response. - [ ] The session cookie is
HttpOnly,Secure,SameSite=Lax, and encrypted with a strongSESSION_PASSWORD(or holds only an ID for a server-side store). - [ ] Login and logout are full page navigations; logout is a
POSTroute handler that checksOrigin. - [ ] The callback requires the login-pending marker set by
/auth/login. - [ ] Calls to Central send
Cookie: iesf_auth_session=<URL-encoded token>, plusOriginon writes, withcache: 'no-store'. - [ ] Route handlers that change data check
Origin; client components call them without anAuthorizationheader. - [ ] A
401from Central or your backend means "signed out": clear the session in a route handler and send the user to/auth/login. A5xxmeans "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.tsthat redirects to/auth/loginwhen theapp_sessioncookie 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()andcentralFetch()like route handlers; Next.js already checks theirOrigin.
Next: Platform integration checklist · Authentication flow · API reference