Skip to content

Quickstart: React SPA + Express BFF ​

A complete, working example of how a platform signs users in through the IESF Central Gateway without the gateway token ever reaching the browser, for a React single-page app (Vite, Create React App…) with its own Node.js BFF.

Using Next.js? See Quickstart: Next.js (App Router), where Next.js itself is the BFF.

It uses three parts:

PartRunsKnows the gatewayToken?
React appIn the browserNo. It only has your own HttpOnly session cookie, which JavaScript cannot read
BFF (backend for frontend, here Express)Your server, same origin as the React appYes, in a server-side session store
Backend API (optional)Your server, private networkReceives it from the BFF per request and checks it with Central

The BFF and the React app must be served from the same origin (for example https://app.example.com for the page and https://app.example.com/api/…, …/auth/… for the BFF, behind one reverse proxy). Then the browser sends your session cookie automatically, no CORS is involved, and no token is needed in JavaScript.

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 the BFF
  • the backend key of the platform for the backend API

1. BFF (Express, TypeScript) ​

ts
import express, { type NextFunction, type Request, type Response } from 'express';
import session from 'express-session';
import { randomBytes } from 'node:crypto';

const CENTRAL = process.env.CENTRAL_BASE!;           // https://auth.iesf.gg
const APP_BASE_URL = process.env.APP_BASE_URL!;      // https://app.example.com (a registered return URL)
const APP_ORIGIN = new URL(APP_BASE_URL).origin;
const PLATFORM_KEY = process.env.PLATFORM_KEY!;      // MY_APP
const ENVIRONMENT_KEY = process.env.ENVIRONMENT_KEY ?? 'PROD';
const BACKEND_URL = process.env.BACKEND_URL!;        // http://orders-api.internal:8080, not reachable from browsers
const BFF_BASIC = `Basic ${Buffer.from(`${process.env.BFF_CLIENT_ID}:${process.env.BFF_CLIENT_SECRET}`).toString('base64')}`;

declare module 'express-session' {
  interface SessionData {
    loginPending?: string;
    gatewayToken?: string;
    user?: { id: string; issuer: string; sub: string; permissions: string[] };
  }
}

const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(session({
  name: 'app_session',
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  // Use a shared store (Redis, PostgreSQL…) in production. The browser only ever gets the session ID.
  // store: new RedisStore({ client: redis }),
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/' },
}));

// Writes from the React app are same-origin; anything else is a cross-site request forgery attempt.
function sameOrigin(req: Request, res: Response, next: NextFunction) {
  if (req.method === 'GET' || req.method === 'HEAD' || req.get('origin') === APP_ORIGIN) return next();
  res.status(403).json({ error: 'forbidden' });
}

function requireUser(req: Request, res: Response, next: NextFunction) {
  if (req.session.gatewayToken) return next();
  res.status(401).json({ error: 'signed_out' });
}

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

// 1) Login and registration: a full page navigation, never fetch().
app.get('/auth/login', (req, res) => {
  const wanted = typeof req.query.returnTo === 'string' ? req.query.returnTo : '/';
  const returnTo = wanted.startsWith('/') && !wanted.startsWith('//') ? wanted : '/';
  req.session.loginPending = randomBytes(16).toString('hex'); // login CSRF guard, checked in the callback
  const start = new URL('/v1/auth/start', CENTRAL);
  start.searchParams.set('platformKey', PLATFORM_KEY);
  start.searchParams.set('returnTo', returnTo);
  req.session.save(() => res.redirect(start.toString()));
});

// 2) Callback: exchange the one-time code (valid ~60 s) for the user and the gateway token.
app.get('/auth/callback', async (req, res) => {
  const code = req.query.code;
  if (typeof code !== 'string' || !code.startsWith('handoff_') || !req.session.loginPending) {
    return res.redirect('/?login=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 }),
  }).catch(() => null);
  if (!response?.ok) return res.redirect('/?login=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 res.redirect('/?login=failed');
  }
  // A fresh session ID after login prevents session fixation.
  req.session.regenerate((error) => {
    if (error) return res.redirect('/?login=failed');
    req.session.gatewayToken = exchange.session.gatewayToken;
    req.session.user = { id: exchange.user.id, issuer: exchange.user.issuer, sub: exchange.user.sub, permissions: exchange.permissions };
    req.session.cookie.expires = new Date(exchange.session.expiresAt); // end together with the gateway session
    req.session.save(() => res.redirect(exchange.returnTo));
  });
});

// 3) The signed-in user for the React app: data only, never the token.
app.get('/api/me', requireUser, async (req, res) => {
  const response = await central(req, '/v1/users/me');
  if (response.status === 401) return req.session.destroy(() => res.status(401).json({ error: 'signed_out' }));
  if (!response.ok) return res.status(502).json({ error: 'central_unavailable' });
  const me = await response.json();
  res.json({ id: me.localUser.id, email: me.localUser.primaryEmail, profile: me.profile });
});

// 4) Your own backend API, proxied with the token in a server-to-server header.
app.use('/api/orders', requireUser, sameOrigin, async (req, res) => {
  const response = await fetch(`${BACKEND_URL}/orders${req.url === '/' ? '' : req.url}`, {
    method: req.method,
    headers: { 'Content-Type': 'application/json', 'X-Gateway-Token': req.session.gatewayToken! },
    body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body),
  }).catch(() => null);
  if (!response) return res.status(503).json({ error: 'backend_unavailable' });
  if (response.status === 401) return req.session.destroy(() => res.status(401).json({ error: 'signed_out' }));
  res.status(response.status).type(response.headers.get('content-type') ?? 'application/json').send(Buffer.from(await response.arrayBuffer()));
});

// 5) Logout: POST only, ends your session, the gateway session and the identity session.
app.post('/auth/logout', sameOrigin, async (req, res) => {
  const token = req.session.gatewayToken;
  await new Promise((done) => req.session.destroy(done));
  res.clearCookie('app_session', { path: '/' });
  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}/` }),
    }).catch(() => null);
    if (response?.ok) logoutUrl = (await response.json()).logoutUrl;
  }
  res.json({ redirectTo: logoutUrl ?? '/' });
});

app.listen(3000);

2. React app ​

The React code never sees a token. It navigates to the BFF for login, calls /api/* with its normal cookie, and posts to the BFF for logout.

tsx
import { useEffect, useState } from 'react';

type Me = { id: string; email: string | null; profile: { firstName: string | null } };

export function useMe() {
  const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = loading, null = signed out
  useEffect(() => {
    fetch('/api/me', { credentials: 'same-origin' })
      .then((response) => (response.ok ? response.json() : null))
      .then(setMe, () => setMe(null));
  }, []);
  return me;
}

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

export async function signOut() {
  const response = await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
  const { redirectTo } = response.ok ? await response.json() : { redirectTo: '/' };
  window.location.assign(redirectTo); // the identity logout, then back to your site
}

// Any call to your API: no Authorization header, the session cookie is enough.
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(`/api${path}`, {
    ...init,
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json', ...init.headers },
  });
  if (response.status === 401) {
    signIn();
    throw new Error('signed_out');
  }
  if (!response.ok) throw new Error(`api_${response.status}`);
  return response.json() as Promise<T>;
}

export function Header() {
  const me = useMe();
  if (me === undefined) return null;
  return me
    ? <span>{me.profile.firstName ?? me.email} <button onClick={signOut}>Sign out</button></span>
    : <button onClick={signIn}>Sign in</button>;
}

3. Backend API (introspection) ​

The backend trusts no one blindly: it checks every token with Central, using the platform's backend key, and caches only active results for at most 45 seconds.

ts
import express from 'express';
import { createHash } from 'node:crypto';

const CENTRAL = process.env.CENTRAL_BASE!;
const PLATFORM_KEY = process.env.PLATFORM_KEY!;
const BACKEND_BASIC = `Basic ${Buffer.from(`${process.env.BACKEND_CLIENT_ID}:${process.env.BACKEND_CLIENT_SECRET}`).toString('base64')}`;

type Introspection = { active: boolean; expiresAt?: string; platformKey?: string; user?: { id: string; issuer: string; subject: string; email: string | null } };
const cache = new Map<string, { result: Introspection; until: number }>();

async function introspect(token: string): Promise<Introspection> {
  const key = createHash('sha256').update(token).digest('hex'); // never key a cache by the raw token
  const hit = cache.get(key);
  if (hit && hit.until > Date.now()) return hit.result;
  const response = await fetch(`${CENTRAL}/v1/auth/introspect`, {
    method: 'POST',
    headers: { Authorization: BACKEND_BASIC, 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({ token }),
    signal: AbortSignal.timeout(5_000),
  });
  if (!response.ok) throw new Error(`introspect_${response.status}`); // 401 = our key is wrong, 5xx = Central down
  const result = await response.json() as Introspection;
  if (result.active) cache.set(key, { result, until: Math.min(Date.now() + 45_000, Date.parse(result.expiresAt!)) });
  return result;
}

const app = express();
app.use(express.json());
app.use(async (req, res, next) => {
  const token = req.get('x-gateway-token');
  if (!token) return res.status(401).json({ error: 'signed_out' });
  let result: Introspection;
  try { result = await introspect(token); } catch { return res.status(503).json({ error: 'auth_unavailable' }); } // do not sign the user out
  if (!result.active || result.platformKey !== PLATFORM_KEY) return res.status(401).json({ error: 'signed_out' });
  res.locals.user = result.user; // key your data by (user.issuer, user.subject) or user.id
  next();
});

app.get('/orders', (req, res) => res.json({ owner: res.locals.user.id, orders: [] }));
app.listen(8080);

Checklist: the token never reaches the browser ​

  • [ ] gatewayToken, the handoff code and the client secrets live only on servers (session store, environment variables, secret manager).
  • [ ] The browser cookie is your own session ID: HttpOnly, Secure, SameSite=Lax. Do not put the token into a readable cookie, localStorage, sessionStorage, a URL or a response body.
  • [ ] React and the BFF share one origin; React calls /api/* with credentials: 'same-origin' and never sends an Authorization header.
  • [ ] BFF writes check Origin (CSRF); logout is a POST.
  • [ ] Login and logout are full page navigations (window.location), never fetch() or iframes.
  • [ ] The callback requires the login-pending marker set at /auth/login and regenerates the session ID.
  • [ ] Calls to Central send Cookie: iesf_auth_session=<URL-encoded token>, plus Origin on writes.
  • [ ] The backend API is not exposed to browsers, receives the token only from the BFF, and introspects it with the backend key; it caches only active results, 30–60 s at most.
  • [ ] A 401 from Central or your backend means "signed out": destroy the session and let React send the user to signIn(). A 5xx means "unavailable": do not sign the user out.

Variations ​

  • Next.js. See the separate Quickstart: Next.js (App Router).
  • No separate backend. Drop step 3: the BFF itself is the backend, and its session already proves the user. Introspect from the BFF only if you need to notice a logout or revocation before the session expires.
  • SPA on another origin. Not recommended: cookies become cross-site and need SameSite=None plus CORS with credentials. Put a reverse proxy in front so the app and the BFF share one origin.

Next: Platform integration checklist · Authentication flow · API reference