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 expressThe 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.
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.mjsCreate the adapter
Create the adapter in its own module so route files can import its guards.
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.
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 fromreq.url. - Protocol mounts outside the base path, such as
/oauthor/.well-known, need their prefixes inserve(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.
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.
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
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', …).
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.
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.
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:
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
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.
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.
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.
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.
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().
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.
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.
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);
}
}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:
| Option | Type | Default | Description |
|---|---|---|---|
loginPath | string | none | Where signed-out page navigations (a GET with Accept: text/html) are redirected, with ?next=. Without it they get the JSON 401 |
stepUpPath | string | none | Where page navigations go when the session must step up, with ?next= and ?reason=. Without it they get the JSON 403 |
serve | string[] | [basePath] | Path prefixes the IAM server answers. Add protocol mounts outside the base path, such as /oauth or /.well-known |
basePath | string | the instance's endpoint.basePath, else /api/iam | Where the IAM API is served; the default serve prefix and the in-process client's target |
trustedOrigins | string[] | [] | Origins besides the request's own and the IAM origin that may send cookie-authenticated unsafe requests to guarded routes |
csrf | boolean | true | Check the Origin of cookie-authenticated POST, PUT, PATCH, and DELETE requests in guards. Turn off only when something else checks origins |
now | () => number | Date.now | The clock for step-up recency checks, for tests |
The returned object:
| Member | What it does |
|---|---|
middleware | Serves 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 |
errorHandler | Error 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
| Code | Status | When |
|---|---|---|
UNAUTHENTICATED | 401 | A guard or requireSession ran with no usable session |
MFA_REQUIRED | 403 | stepUp.mfa was not met; reason is mfa |
RECENT_AUTH_REQUIRED | 403 | stepUp.maxAgeMs was not met; reason is recent |
IMPERSONATION_RESTRICTED | 403 | An impersonated session met a maxAgeMs or 'fresh' requirement; reason is impersonation |
ACCESS_DENIED | 403 | authorize or req.iam.require was denied |
UNTRUSTED_ORIGIN | 403 | A cookie-authenticated unsafe request came from an untrusted origin |
CSRF_REJECTED | 403 | A cookie-authenticated unsafe request had no Origin |
INVALID_INPUT | 400 | An 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.
Related
The same helpers and guards for Hono on Node, Bun, Deno, and Workers.
FastifyA plugin with preHandler guards and request.iam.
The per-request helpers, the CSRF rule, and the core for other frameworks.
Typed clientCall the API you mounted from the browser.
PoliciesWhat authorize guards evaluate.
Organization sign-in addresses and pinning.
Better IAM is created by Sean Filimon
Last updated
React Router
Root middleware, guarded loaders and actions, and an API resource route for React Router framework mode (v7.9+ and v8), with React hooks in the browser.
Hono
Serve the Better IAM API from a Hono 4 app with createIamHono, read the session from c.get('iam'), and guard routes with requireSession and authorize.