Express, Hono, and Fastify
Middleware that serves the IAM API, adds req.iam helpers, guards routes, and maps refusals to JSON or redirects, with a core for other Node frameworks.
@better-iam/middleware connects a betterIam() instance to the common Node server frameworks. You could call the
core API from every route yourself. Each route would then read the from headers, turn refusals into the
right status or redirect, check the Origin of cookie posts, and copy the cookies a sign-in issues onto the
response. The adapters do those things once, the same way in Express, Hono, and Fastify:
- Serve the IAM HTTP API. Requests under the instance's
basePath(/api/iam), plus any prefixes you add withserve, go to the IAM server. Express and Fastify useiam.nodeHandler, so node-only protocol mounts such as the OAuth provider work too. - Add per-request helpers as
req.iam(Express),c.get('iam')(Hono), orrequest.iam(Fastify): the session, decisions, an in-process client, and sign-out. - Guard routes.
requireSession({ stepUp? })andauthorize(action, { resource?, tenantId?, stepUp? })check the session, then , then authorization. - Check where requests come from. The same guards refuse cookie-authenticated
POST,PUT,PATCH, andDELETErequests sent by another site's page (the CSRF check). - Map refusals. API calls get the IAM JSON error envelope
{ error: { code, message } }with the IAM status (401, 403, 429) andCache-Control: no-store. Page navigations (aGETwithAccept: text/html) are redirected tologinPath?next=…when signed out, or tostepUpPath?next=…&reason=mfa|recent|impersonationwhen the session must step up, if those options are set.
These adapters have no browser side and no hydration step of their own. If you render Vue on the server, pass
req.iam.client and the session to the Vue plugin; for
React, pass the session to IamProvider as initialSession.
NestJS, Next.js, Nuxt, and SvelteKit have their own integrations (NestJS, Next.js, Nuxt, SvelteKit). SvelteKit's and React Router's are built on this same core.
Install
npm i better-iamThe umbrella exposes the adapters as better-iam/express, better-iam/hono, and better-iam/fastify, and the
framework-neutral core as better-iam/middleware. With scoped packages, install @better-iam/middleware and
import from @better-iam/middleware/express, /hono, /fastify, or the root. Express 4 and 5 and Fastify 4 and
5 are supported; the Hono adapter targets Hono 4.
Setup
Type the request helpers
import type { IamRequest } from 'better-iam/middleware';
import type { iam } from './iam.js';
declare global {
namespace Express {
interface Request {
iam: IamRequest<typeof iam>;
}
}
}Mount the adapter
import express from 'express';
import { createIamExpress } from 'better-iam/express';
import { iam } from './iam.js';
const iamExpress = createIamExpress(iam, { loginPath: '/login', stepUpPath: '/verify' });
const app = express();
app.use(iamExpress.middleware); // before express.json(): the IAM server reads the raw body
app.use(express.json());Mount the middleware before body parsers. If a parser runs first, the adapter re-serializes the parsed body for
the IAM API (up to 2 MiB; larger bodies answer 413). That works for the JSON API, but node-only protocol mounts
need the untouched stream. Mounting under a path (app.use('/api/iam', iamExpress.middleware)) works too,
because the adapter restores the path Express strips.
Guard routes
app.get('/me', iamExpress.requireSession(), async (req, res) => {
const session = await req.iam.getSession();
res.json({ email: session!.identity.email, canInvite: await req.iam.can('iam:identities:create') });
});
app.get(
'/projects/:id',
iamExpress.authorize('projects:read', {
resource: (req) => ({ type: 'project', id: req.params.id }),
}),
(req, res) => res.json(loadProject(req.params.id)),
);Answer refusals your routes throw
Guards answer their own refusals. Install the error handler for refusals thrown later, by req.iam.require(...) or
direct iam.api.* calls inside a route.
app.use(iamExpress.errorHandler); // last: refusals become JSON or a redirect; other errors go to next(error)What each adapter returns
createIamExpress, createIamHono, and createIamFastify take the instance (or a factory that returns it) and
the options below, and return the same set of members, shaped for each framework:
| Member | Express | Hono | Fastify | What it does |
|---|---|---|---|---|
| Mount | middleware | middleware | plugin | Serves the IAM API under the serve prefixes and attaches the helpers to every other request |
requireSession({ stepUp? }) | middleware | middleware | preHandler | Requires a session, and a step-up when given |
authorize(action, { resource?, tenantId?, stepUp? }) | middleware | middleware | preHandler | Requires a session allowed to perform action. resource and tenantId are (req, session) => ...; the resource defaults to the tenant (iam/{tenantId}) and the tenant to the session's |
| Error handling | errorHandler | onError | errorHandler | Turns IAM refusals thrown by routes into the JSON envelope or a redirect |
helpers(...) | (req, res) | (c) | (request, reply) | The per-request helpers, created on first use |
resolve() | yes | yes | yes | Resolves the instance (the adapter accepts the instance or a factory) |
Options
Prop
Type
The CSRF check
Guards also run a CSRF check. A POST, PUT, PATCH, or DELETE authenticated by the session cookie, with no
Authorization header, must carry an Origin of the app itself, the IAM origin, or one of trustedOrigins; a
Sec-Fetch-Site: same-origin request also passes. Otherwise it is refused with UNTRUSTED_ORIGIN, or
CSRF_REJECTED when there is no Origin.
Why it matters: session cookies are SameSite=Lax, which browsers still send with form posts from sibling
subdomains, so without the check a page on another subdomain could make changes as the signed-in person. Bearer
tokens skip it because browsers never attach them on their own. Routes without a guard that change state should
call checkRequestOrigin themselves (see the core).
Per-request helpers
Inside a route, req.iam (or c.get('iam'), request.iam) is how you ask about the current caller without
passing headers around. The helpers are created once per request and memoize the session lookup.
| Member | Returns / does |
|---|---|
getSession() | The session, or null (memoized for the request) |
requireSession({ stepUp? }) | The session, or throws IamRequestError (401, or 403 with a reason) |
require(action, resource?, { tenantId? }) | Throws the server's refusal when not allowed |
can(action, resource?, { tenantId? }) | An advisory boolean; checks made in the same tick share one authorizeMany call |
authorize(checks, { tenantId? }) | { action, resource, allowed, reason }[]; every check is denied (UNAUTHENTICATED) when signed out |
listAccessible({ action, type, tenantId?, limit?, offset? }) | The managed resources the caller may act on |
assertion({ tenantId, audience, ttlSeconds?, claims? }) | A signed for a downstream service |
client | A typed client calling the IAM handler in process; its Set-Cookie answers go on this response |
credential() | { headers } for iam.api.* calls, including cookies set earlier in the request |
signOut() | Ends the session and clears its cookie; never throws for an already-dead session |
The resource defaults to the tenant (iam/{tenantId}), and the tenant to the session's. Cookies the in-process
client receives override the incoming ones for later calls in the same request, so getSession() after a sign-in
sees the new session.
Sign in without client JavaScript
import { safeRedirectPath } from 'better-iam/middleware';
app.post('/login', async (req, res) => {
// The in-process client sets the session cookie on this response.
const result = await req.iam.client.auth.signIn({
tenantId, // your organization's id, for example from client.tenants.lookup({ slug })
email: req.body.email,
password: req.body.password,
});
if ('mfaRequired' in result) return res.redirect(303, `/verify?challenge=${result.challenge}`);
res.redirect(303, safeRedirectPath(String(req.query.next ?? '')));
});
app.post('/logout', async (req, res) => {
await req.iam.signOut();
res.redirect(303, '/');
});A plain HTML form posts application/x-www-form-urlencoded, so req.body needs
app.use(express.urlencoded({ extended: false })), registered after the IAM middleware like express.json().
safeRedirectPath (from better-iam/middleware) accepts only same-site paths and falls back to /, so a ?next=
parameter cannot become an open redirect. A failed sign-in rejects with the server's error (for example
INVALID_CREDENTIALS or RATE_LIMITED). Express 5 passes that rejection to errorHandler, which answers with the
JSON envelope; on Express 4, catch it and call next(error), or render your form again with the code.
Other frameworks
The package root exports the framework-neutral pieces the adapters are built from:
| Export | What it does |
|---|---|
createRequestHelpers(resolveIam, { url, headers, setCookie }, { basePath?, now? }) | Builds the per-request helpers over any request and response; setCookie appends one Set-Cookie header to your response |
enforceGuard(helpers, request, spec, origin?) | Runs a guard: session, step-up, then authorization, with the origin check first when origin is given. Resolves with the session or throws the refusal |
refusalResponse(error, request, { loginPath?, stepUpPath? }) | Decides how to answer a refusal: { redirect } for page navigations, else { status, body } with the JSON envelope |
checkRequestOrigin(request, trustedOrigins) | The CSRF rule above; returns the refusal or null |
checkStepUp(session, requirement, now?) | The step-up rule; returns null or { code, reason, message, status } |
safeRedirectPath(value, fallback?) | A same-site path, or the fallback |
IamRequestError, isIamRefusal(error), isAuthenticationError(error) | The refusal class the helpers throw (code, status, reason), and tests for IAM refusals and for "no usable session" errors |
errorBody(error), errorCode(error), errorStatus(error) | The JSON envelope string for a refusal, and safe readers for an error's code and status |
underPath(pathname, prefix), withQuery(target, params) | Prefix matching on path-segment boundaries (/admin covers /admin/users, not /administrator), and appending query parameters |
nodeHeaders(record), parseCookieHeader(header), setCookieSummary(header) | Convert Node header records to Headers, split a Cookie header, and read a Set-Cookie header's name, value, and whether it deletes the cookie |
tenantOf(session) | The tenant a session acts in |
import { createRequestHelpers, enforceGuard, isIamRefusal, refusalResponse } from 'better-iam/middleware';
import { iam } from './iam.js';
export async function handle(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith('/api/iam/')) return iam.handler(request);
const cookies: string[] = [];
const helpers = createRequestHelpers(async () => iam, {
url,
headers: request.headers,
setCookie: (header) => cookies.push(header),
});
let response: Response;
try {
const session = await enforceGuard(helpers, request, { authorize: { action: 'projects:read' } }, {
method: request.method,
url,
headers: request.headers,
trustedOrigins: [iam.endpoint?.origin],
});
response = Response.json({ projects: await listProjects(session) });
} catch (error) {
if (!isIamRefusal(error)) throw error;
const answer = refusalResponse(error, { url, headers: request.headers, method: request.method }, { loginPath: '/login' });
response =
'redirect' in answer
? new Response(null, { status: 303, headers: { location: answer.redirect } })
: new Response(answer.body, { status: answer.status, headers: { 'content-type': 'application/json' } });
}
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
return response;
}Protocol mounts covers serving OAuth, SAML, and SCIM endpoints beside the JSON API.
Next steps
Was this page helpful?
Last updated on