BetterIAM

Express

Serve the Better IAM API from an Express 4 or 5 app with createIamExpress, read the session from req.iam, and guard routes with requireSession and authorize.

createIamExpress connects a betterIam() instance to an Express (or Connect) application. One middleware serves the IAM HTTP API under /api/iam and gives every other request a req.iam object with the caller's , permission checks, and an in-process client. Route guards (requireSession, authorize) refuse signed-out callers, callers without a permission, and cross-site form posts, and an error handler turns refusals into the IAM JSON error envelope or a redirect to your login page.

Use it when your server is an Express app. For Hono and Fastify, use the Hono and Fastify adapters; they expose the same helpers. For a plain node:http server or a framework with no adapter, build on the framework-neutral core.

Installation

Install the package

npm i better-iam express

The umbrella package better-iam exposes the adapter as better-iam/express and the shared core as better-iam/middleware. If you install scoped packages instead, install @better-iam/middleware and import from @better-iam/middleware/express and @better-iam/middleware. The adapter's types cover Express 4 and 5; the repository's tests run it on Express 5.

Create the instance

Create the instance once and import it everywhere. The configuration file is the one the quickstart builds, which the better-iam CLI also reads for migrations.

src/iam.ts
import { betterIam } from 'better-iam';
import config from '../better-iam.config.mjs';

export const iam = betterIam(config);

Run the migrations before the first start:

npx better-iam migrate --config better-iam.config.mjs

Create the adapter

Create the adapter in its own module so route files can import its guards.

src/iam-express.ts
import { createIamExpress } from 'better-iam/express';
import { iam } from './iam.js';

export const iamExpress = createIamExpress(iam, {
  loginPath: '/login', // signed-out page navigations go here with ?next=
  stepUpPath: '/verify', // page navigations that must step up go here with ?next= and ?reason=
});

Mount the handler

Register iamExpress.middleware before any body parser. It answers every request under the instance's base path (/api/iam) and attaches req.iam to all other requests. Register errorHandler after your routes.

src/server.ts
import express from 'express';
import { iamExpress } from './iam-express.js';
import { projects } from './routes/projects.js';

const app = express();
app.use(iamExpress.middleware); // before express.json(): the IAM server reads the raw body
app.use(express.json());
app.use(express.urlencoded({ extended: false })); // for plain HTML form posts

app.use(projects);

app.use(iamExpress.errorHandler); // last: IAM refusals thrown by routes become JSON or a redirect
app.listen(3000);

The route examples below each export an Express Router; mount each one the same way, with app.use(router), between the body parsers and the error handler.

  • Order matters. The IAM server reads the raw request stream, which also lets node-only protocol mounts (the OAuth provider) work. If a parser runs first, the adapter re-serializes the parsed body for the JSON API (up to 2 MiB; larger bodies answer 413), but protocol mounts need the untouched stream.
  • Mounting under a path (app.use('/api/iam', iamExpress.middleware)) also works: the adapter restores the path Express strips from req.url.
  • Protocol mounts outside the base path, such as /oauth or /.well-known, need their prefixes in serve (see Protocol mounts).

Type req.iam

Declare the helpers on Express's Request so req.iam is typed with your instance's session and API.

src/types.d.ts
import type { IamRequest } from 'better-iam/middleware';
import type { iam } from './iam.js';

declare global {
  namespace Express {
    interface Request {
      iam: IamRequest<typeof iam>;
    }
  }
}

Create the browser client

Browser code calls the API you just mounted with the typed client. Import the instance with import type so no server code reaches the bundle.

web/iam-client.ts
import { createIamClient } from 'better-iam/client';
import type { iam } from '../src/iam.js';

export const client = createIamClient<typeof iam>({
  baseURL: window.location.origin, // the Express app's origin; calls go to /api/iam/{group}/{method}
});

A front end served from a different origin than the Express app also needs that origin in the instance's trustedOrigins option (for the IAM API) and in the adapter's trustedOrigins option (for your guarded routes).

Protect routes

Guards are Express middleware. Each one reads the session, checks when asked, then checks authorization, and answers a refusal itself: a 401 or 403 JSON envelope for API calls, or a 303 redirect to loginPath or stepUpPath for page navigations (a GET with Accept: text/html).

Require a session

src/routes/me.ts
import { Router } from 'express';
import { iamExpress } from '../iam-express.js';

export const me = Router();

me.get('/me', iamExpress.requireSession(), async (req, res) => {
  const session = await req.iam.getSession();
  res.json({ email: session!.identity.email });
});

A signed-out API call gets 401 with { "error": { "code": "UNAUTHENTICATED", "message": "A session is required" } } and Cache-Control: no-store. A signed-out page navigation to /me?tab=1 is redirected to /login?next=%2Fme%3Ftab%3D1.

Require a permission

authorize(action, { resource?, tenantId?, stepUp? }) requires a session allowed to perform action. The resource defaults to the tenant itself (iam/{tenantId}) and the tenant to the session's tenant. resource and tenantId are functions of (req, session). Their req carries the route parameters (req.params). For typed parameters, pass the request type, as in authorize<Request<{ id: string }>>('projects:read', …).

src/routes/projects.ts
import { Router } from 'express';
import { iam } from '../iam.js';
import { iamExpress } from '../iam-express.js';
import { loadProject } from '../data.js'; // your data layer

export const projects = Router();

projects.get(
  '/projects/:id',
  iamExpress.authorize('projects:read', {
    resource: (req) => ({ type: 'project', id: String(req.params.id) }),
  }),
  async (req, res) => {
    res.json(await loadProject(String(req.params.id)));
  },
);

projects.get('/admin/people', iamExpress.authorize('iam:identities:read'), async (req, res) => {
  const session = await req.iam.requireSession();
  res.json(
    await iam.api.identities.list(req.iam.credential(), { tenantId: session.session.tenantId }),
  );
});

The check runs through iam.require, so it evaluates the same , , , and as every other check. A denial answers 403 ACCESS_DENIED.

Require step-up

stepUp asks for more assurance than a session: mfa: true needs a session that completed MFA, mfa: 'fresh' also refuses remembered devices, impersonation, assumed roles, and API keys, and maxAgeMs needs a recent sign-in.

src/routes/billing.ts
import { Router } from 'express';
import { iamExpress } from '../iam-express.js';
import { cancelSubscription } from '../data.js'; // your data layer

export const billing = Router();

billing.post(
  '/billing/cancel',
  iamExpress.authorize('billing:manage', { stepUp: { mfa: true, maxAgeMs: 10 * 60 * 1000 } }),
  async (_req, res) => {
    await cancelSubscription();
    res.status(204).end();
  },
);

A session that fails the requirement gets 403 with MFA_REQUIRED, RECENT_AUTH_REQUIRED, or IMPERSONATION_RESTRICTED. Page navigations go to stepUpPath?next=...&reason=mfa|recent|impersonation. See Step-up for completing the challenge.

Enforce inside a route

When the resource is only known after loading data, enforce inside the handler with req.iam.require. It throws the server's refusal, which errorHandler answers.

src/routes/documents.ts
import { Router } from 'express';
import { iamExpress } from '../iam-express.js';
import { deleteDocument, findDocument } from '../data.js'; // your data layer

export const documents = Router();

documents.delete('/documents/:id', iamExpress.requireSession(), async (req, res) => {
  const document = await findDocument(String(req.params.id));
  await req.iam.require('documents:delete', { type: 'document', id: document.id });
  await deleteDocument(document.id);
  res.status(204).end();
});

Express 5 passes a rejected promise from an async handler to the error middleware. On Express 4, wrap the body in try { ... } catch (error) { next(error); } so the refusal reaches errorHandler.

Cross-site requests

Guards also refuse a cookie-authenticated POST, PUT, PATCH, or DELETE whose Origin is not the app itself, the IAM origin, or one of trustedOrigins (403 UNTRUSTED_ORIGIN, or 403 CSRF_REJECTED with no Origin). A Sec-Fetch-Site: same-origin request passes, and bearer-token requests skip the check. A state-changing route without a guard should run the same rule with checkRequestOrigin:

src/routes/newsletter.ts
import { Router } from 'express';
import { checkRequestOrigin, nodeHeaders } from 'better-iam/middleware';
import { iam } from '../iam.js';
import { subscribe } from '../data.js'; // your data layer

export const newsletter = Router();

newsletter.post('/newsletter/subscribe', async (req, res, next) => {
  const refusal = checkRequestOrigin(
    {
      method: req.method,
      url: new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`),
      headers: nodeHeaders(req.headers),
    },
    [iam.endpoint.origin],
  );
  if (refusal) return next(refusal); // errorHandler answers it
  await subscribe(String(req.body.email));
  res.status(204).end();
});

The CSRF check explains why it matters with SameSite=Lax cookies.

Read the session

req.iam is created once per request. It memoizes the session lookup, and cookies the in-process client receives count for later calls in the same request.

Get the session

src/routes/home.ts
import { Router } from 'express';

export const home = Router();

home.get('/', async (req, res) => {
  const session = await req.iam.getSession(); // null when signed out
  if (!session) return res.redirect(303, '/login');
  res.json({
    name: session.identity.name,
    email: session.identity.email,
    tenantId: session.session.tenantId,
    mfa: session.session.mfa,
  });
});

getSession() resolves to null instead of throwing when the request has no usable session: no credential, or the server answers UNAUTHENTICATED, MFA_REQUIRED, EMAIL_UNVERIFIED, TENANT_INACTIVE, or TENANT_UNAVAILABLE. requireSession({ stepUp? }) returns the session or throws an IamRequestError (401, or 403 with a reason). The helper calls iam.api.auth.getSession in process; browsers reach the same method over HTTP.

Route: POST /api/iam/auth/getSession

Check permissions for rendering

can returns an boolean. Checks made in the same tick share one authorizeMany call, so a page with many buttons costs one round trip.

src/routes/toolbar.ts
import { Router } from 'express';
import { iamExpress } from '../iam-express.js';

export const toolbar = Router();

toolbar.get('/projects/:id/toolbar', iamExpress.requireSession(), async (req, res) => {
  const project = { type: 'project', id: String(req.params.id) };
  const [canEdit, canDelete, canInvite] = await Promise.all([
    req.iam.can('projects:update', project),
    req.iam.can('projects:delete', project),
    req.iam.can('iam:identities:create'),
  ]);
  res.json({ canEdit, canDelete, canInvite });
});

req.iam.authorize(checks, { tenantId? }) returns { action, resource, allowed, reason }[] for a list of checks. Advisory checks decide what to show; enforce the operation itself with a guard or req.iam.require.

Filter lists

plan({ action, type, tenantId? }) returns the resources of a type the caller may act on as a filter for your own query (never when signed out). listAccessible({ action, type, tenantId?, limit?, offset? }) lists registered resources of a managed type.

src/routes/document-list.ts
import { Router } from 'express';
import { filterToSql } from 'better-iam/core';
import { iamExpress } from '../iam-express.js';
import { columns, db } from '../data.js'; // your data layer: a field-to-column map and a SQL client

export const documentList = Router();

documentList.get('/documents', iamExpress.requireSession(), async (req, res) => {
  const plan = await req.iam.plan({ action: 'documents:read', type: 'document' });
  if (plan.kind === 'never') return res.json([]);
  const where = filterToSql(plan.filter, {
    dialect: 'postgres',
    column: (field) => columns[field],
    offset: 1,
  });
  const sql = `SELECT * FROM documents d WHERE d.tenant_id = $1 AND ${where.sql}`;
  res.json(await db.query(sql, [plan.tenantId, ...where.params]));
});

See Data filtering for the plan format and the other compilers.

Sign in and out

The in-process client (req.iam.client) is the full typed client with iam.handler as its transport. The cookies the server issues land on this response, so a plain HTML form can sign someone in without client JavaScript.

src/routes/auth.ts
import { Router } from 'express';
import { safeRedirectPath } from 'better-iam/middleware';
import { iam } from '../iam.js';

export const auth = Router();

auth.post('/login', async (req, res) => {
  const { tenantId } = await iam.api.tenants.lookup({ slug: String(req.body.org) });
  const result = await req.iam.client.auth.signIn({
    tenantId,
    email: String(req.body.email),
    password: String(req.body.password),
  });
  if ('mfaRequired' in result) return res.redirect(303, `/verify?challenge=${result.challenge}`);
  res.redirect(303, safeRedirectPath(String(req.query.next ?? '')));
});

auth.post('/logout', async (req, res) => {
  await req.iam.signOut(); // ends the session and clears its cookie; never throws for a dead session
  res.redirect(303, '/');
});

Route: POST /api/iam/auth/signIn, POST /api/iam/auth/signOut, POST /api/iam/tenants/lookup

safeRedirectPath accepts only same-site paths and falls back to /, so ?next= cannot become an open redirect. A failed sign-in rejects with the server's error (for example INVALID_CREDENTIALS or RATE_LIMITED), which errorHandler answers on Express 5; catch it to render the form again with the code instead. See MFA for completing a challenge.

Call the server API directly

req.iam.credential() returns { headers } for direct iam.api.* calls, including cookies set earlier in the same request.

src/routes/sessions.ts
import { Router } from 'express';
import { iam } from '../iam.js';
import { iamExpress } from '../iam-express.js';

export const sessions = Router();

sessions.get('/me/sessions', iamExpress.requireSession(), async (req, res) => {
  res.json(await iam.api.auth.listSessions(req.iam.credential()));
});

Route: POST /api/iam/auth/listSessions

API keys and session tokens

requireSession and the authorize guard build on auth.getSession, which serves signed-in people only, so API keys and temporary credentials are refused there with 401. Machine routes check them through the helpers, which pass the request's Authorization header to the server: req.iam.require(action, resource, { tenantId }) with an explicit tenant, or iam.api.* with req.iam.credential().

src/routes/imports.ts
import { Router } from 'express';
import { iam } from '../iam.js';
import { runImport } from '../data.js'; // your data layer

export const imports = Router();

imports.post('/hooks/import', async (req, res, next) => {
  try {
    const caller = await iam.api.sts.getCallerIdentity(req.iam.credential());
    await req.iam.require('imports:run', undefined, { tenantId: caller.tenantId });
    res.json(await runImport(req.body));
  } catch (error) {
    next(error);
  }
});

Route: POST /api/iam/sts/getCallerIdentity

Bearer requests skip the CSRF check. Services that only need to verify session JWTs offline use createSessionTokenVerifier from better-iam/session-tokens; see Temporary access.

Organizations

Every session acts in one (an organization). Guards and helpers default to it: the tenant is session.session.tenantId, and a check with no resource targets the tenant itself.

Check in another tenant

Pass tenantId to check against a different tenant, for example a child organization the URL names. The server decides whether the caller's credential may act there.

src/routes/org-members.ts
import { Router } from 'express';
import { iam } from '../iam.js';
import { iamExpress } from '../iam-express.js';

export const orgMembers = Router();

orgMembers.get(
  '/orgs/:tenantId/members',
  iamExpress.authorize('iam:identities:read', {
    tenantId: (req) => String(req.params.tenantId),
  }),
  async (req, res) => {
    const tenantId = String(req.params.tenantId);
    res.json(await iam.api.identities.list(req.iam.credential(), { tenantId }));
  },
);

Inside a route, the same option is req.iam.require(action, resource, { tenantId }) and req.iam.can(action, resource, { tenantId }).

The organization in the URL

When each organization lives under its own path (/acme/projects), resolve the alias with tenants.lookup and send visitors whose session belongs to another organization to sign in there.

src/organization.ts
import type { NextFunction, Request, Response } from 'express';
import { safeRedirectPath, tenantOf, withQuery } from 'better-iam/middleware';
import { iam } from './iam.js';

export async function requireOrganization(req: Request, res: Response, next: NextFunction) {
  try {
    const slug = String(req.params.org);
    const organization = await iam.api.tenants.lookup({ slug }); // NOT_FOUND for unknown or inactive aliases
    const session = await req.iam.getSession();
    if (tenantOf(session) !== organization.tenantId) {
      const back = safeRedirectPath(req.originalUrl);
      return res.redirect(303, withQuery('/login', { org: slug, next: back }));
    }
    res.locals.organization = organization; // { tenantId, name, type, slug, ... }
    next();
  } catch (error) {
    next(error);
  }
}
src/routes/org-projects.ts
import { Router } from 'express';
import { requireOrganization } from '../organization.js';
import { listProjects } from '../data.js'; // your data layer

export const orgProjects = Router();

orgProjects.get('/:org/projects', requireOrganization, async (req, res) => {
  await req.iam.require('projects:read'); // the session's tenant is the organization's tenant here
  res.json(await listProjects(res.locals.organization.tenantId));
});

Route: POST /api/iam/tenants/lookup

A person with identities in several organizations can list them with links.list and switch with links.switch; see Tenants and identities.

Organization addresses

With organization sign-in addresses (acme.signin.example.com), calls through req.iam.client keep the host the visitor is on, so the server pins them to that organization exactly as it would a browser call: sign-in on Acme's address needs no tenantId, and credentials of other organizations are refused with HOST_MISMATCH. iam.hosts.resolve(host) tells your own middleware which organization an address belongs to.

Options

createIamExpress(iam, options) takes the instance, or a function returning it (or a promise of it), and these options:

OptionTypeDefaultDescription
loginPathstringnoneWhere signed-out page navigations (a GET with Accept: text/html) are redirected, with ?next=. Without it they get the JSON 401
stepUpPathstringnoneWhere page navigations go when the session must step up, with ?next= and ?reason=. Without it they get the JSON 403
servestring[][basePath]Path prefixes the IAM server answers. Add protocol mounts outside the base path, such as /oauth or /.well-known
basePathstringthe instance's endpoint.basePath, else /api/iamWhere the IAM API is served; the default serve prefix and the in-process client's target
trustedOriginsstring[][]Origins besides the request's own and the IAM origin that may send cookie-authenticated unsafe requests to guarded routes
csrfbooleantrueCheck the Origin of cookie-authenticated POST, PUT, PATCH, and DELETE requests in guards. Turn off only when something else checks origins
now() => numberDate.nowThe clock for step-up recency checks, for tests

The returned object:

MemberWhat it does
middlewareServes the IAM API under the serve prefixes and attaches req.iam to every other request. Mount before body parsers
requireSession({ stepUp? })Middleware requiring a session, and a step-up when given
authorize(action, { resource?, tenantId?, stepUp? })Middleware requiring a session allowed to perform action
errorHandlerError middleware: IAM refusals become the JSON envelope or a redirect; other errors go to next(error)
helpers(req, res)The per-request helpers, created on first use (the same object as req.iam)
resolve()Resolves the instance

The members of req.iam are listed under Per-request helpers.

Errors

CodeStatusWhen
UNAUTHENTICATED401A guard or requireSession ran with no usable session
MFA_REQUIRED403stepUp.mfa was not met; reason is mfa
RECENT_AUTH_REQUIRED403stepUp.maxAgeMs was not met; reason is recent
IMPERSONATION_RESTRICTED403An impersonated session met a maxAgeMs or 'fresh' requirement; reason is impersonation
ACCESS_DENIED403authorize or req.iam.require was denied
UNTRUSTED_ORIGIN403A cookie-authenticated unsafe request came from an untrusted origin
CSRF_REJECTED403A cookie-authenticated unsafe request had no Origin
INVALID_INPUT400An authorize guard had no tenant to check in

errorHandler answers any error that carries an IAM code and HTTP status (IamRequestError, the server's IamError, QUOTA_EXCEEDED, NOT_FOUND from tenants.lookup) with that status and { "error": { "code", "message" } }. A body the adapter has to re-serialize above 2 MiB answers 413 with plain text.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page