Skip to main content

Express

The Express middleware, per-route guard, and admin router — signatures, extraction defaults, and error mapping for Express 4 and 5

@gentleduck/iam/server/express exposes two request gates and one admin router. It declares no runtime dependency on Express: every function is typed against a minimal req / res / next shape, so it works with Express 4, Express 5, and any router that implements .get, .put, .post, and .delete. The shared extraction and audit behaviour lives in generic helpers; this page documents only what Express adds.

Install


npm i @gentleduck/iam
import { iamAccessMiddleware, iamGuard, iamAdminRouter } from '@gentleduck/iam/server/express'
import type { IamExpress } from '@gentleduck/iam/server/express'

All three runtime exports carry the iam prefix since 5.0.0. accessMiddleware, guard, and adminRouter no longer resolve, and the namespace is IamExpress, not Express.

Setup

Build the engine once per process

// lib/engine.ts
import { IamEngine } from '@gentleduck/iam/core'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'

export const engine = new IamEngine({ adapter: new IamMemoryAdapter() })

Put your auth layer in front

The wrapper never authenticates. It reads req.user.id, which passport, a session middleware, or your own JWT verifier must have set already.

app.use(sessionMiddleware)
app.use(attachUserFromSession)

Mount a gate

Either one blanket middleware under a prefix, or a guard per route.

app.use('/api', iamAccessMiddleware(engine))
// or
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), deletePostHandler)

Optionally replace the 500 body

Both gates answer a fixed 500 { error: 'Internal server error' } when an extractor or the engine throws. Pass onError if you want a different body or your own logging.

app.delete('/posts/:id', iamGuard(engine, 'delete', 'post', {
  onError: (err, _req, res) => { log.error(err); res.status(503).json({ error: 'Try again' }) },
}), deletePostHandler)

One guarded request

This sequence diagram traces DELETE /posts/42 through the Express stack with iamGuard mounted.

Loading diagram...

Every extractor runs inside the try, getUserId included, so a throwing or rejecting one reaches onError rather than the framework boundary. That placement is Express-specific in its consequences: getUserId is the extractor most likely to do I/O - JWT verification, a session lookup, an IdP call - and an Express 4 middleware that returns a rejected promise writes nothing to the socket, so the client hung until it timed out.

iamAccessMiddleware

Blanket middleware. Derives action and resource from the request, so one app.use covers a whole route tree.

export function iamAccessMiddleware<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  opts?: IamExpress.IOptions<TScope>,
): (req: Req, res: Res, next: Next) => void
OptionTypeDefaultMeaning
getUserId(req) => string | nullreq.user?.id ?? nullSubject id. A falsy return is a 401 before the engine is consulted.
getResource(req) => IamRequest.IResourcefirst two segments of req.path{ type: parts[0] ?? 'root', id: parts[1], attributes: {} }. req.path defaults to '/' when absent.
getAction(req) => stringiamActionForMethod(req.method)Method map from the generic helpers; an unmapped method yields the reserved refusal token, not 'read'.
getEnvironment(req) => IamRequest.IEnvironmentiamExtractEnvironment{ ip, userAgent, timestamp }, with ip left undefined.
getScope(req) => TScope | undefinednone scope stays undefinedMulti-tenant scope.
onDenied(req, res) => voidres.status(403).json({ error: 'Forbidden' })Runs when engine.can() returns false.
onError(err, req, res) => voidres.status(500).json({ error: 'Internal server error' })Runs when any extractor or engine.can() throws. Three arguments — next is not passed.

The 401 response is fixed at { error: 'Unauthorized' } and has no option. The resource.attributes bag is always {} — the middleware never loads the record.

app.use(
  '/api',
  iamAccessMiddleware(engine, {
    getUserId: (req) => req.user?.id ?? null,
    getScope: (req) => (typeof req.headers?.['x-org-id'] === 'string' ? req.headers['x-org-id'] : undefined),
    onDenied: (req, res) => res.status(403).json({ error: 'Forbidden', path: req.path }),
    onError: (err, _req, res) => { log.error(err); res.status(500).json({ error: 'Internal server error' }) },
  }),
)

Path-derived resources

/posts/42 produces { type: 'posts', id: '42' } — plural, taken verbatim from the URL. / produces { type: 'root', id: undefined }. The second segment becomes resource.id even when it is not an identifier, so GET /posts/search checks read on posts with id: 'search'. Supply getResource on such routes, or switch to iamGuard.

An ambiguous path is refused rather than resolved. /posts/../admin, /posts/%2e%2e/admin, /posts/%252e%252e/admin and /posts\..\admin all produce the reserved token 'unknown', which the engine denies before consulting any policy. Express is where this matters most: it served /admin for /admin/../public while a resolving helper had authorized public - authorized as one resource, served as another.

iamGuard

Per-route middleware with a fixed action and resource type. The resource id comes from req.params.id.

export function iamGuard<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  action: TAction,
  resourceType: TResource,
  opts?: Pick<IamExpress.IOptions<TScope>, 'getUserId' | 'getEnvironment' | 'onDenied' | 'onError'> & { scope?: TScope },
): (req: Req, res: Res, next: Next) => void

The guard accepts a strict subset of the middleware options: getUserId, getEnvironment, onDenied, onError, plus a fixed scope. There is no getAction, no getResource, and no getScope — the action and resource type are the arguments, and the id is req.params?.id.

onError defaults to the same fixed 500 { error: 'Internal server error' } the middleware answers. It used to default to next(err), which with no app error handler and NODE_ENV !== 'production' made finalhandler write err.stack into the response body.

// Fixed action and resource; id read from req.params.id.
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), (req, res) => {
  res.json({ deleted: req.params.id })
})

// Fixed scope for a whole admin surface.
app.post('/admin/users', iamGuard(engine, 'manage', 'user', { scope: 'admin' }), createUser)

// Custom subject source for a machine-to-machine route.
app.get(
  '/reports',
  iamGuard(engine, 'read', 'report', {
    getUserId: (req) => serviceAccountFor(req) ?? null,
    // The package never answers 404; this is your own handler choosing to.
    onDenied: (_req, res) => res.status(404).json({ error: 'Not found' }),
  }),
  listReports,
)

Subject, scope, and environment

FieldWhere it comes fromOverride
subject idreq.user?.idgetUserId (both gates)
resource typefirst path segment (middleware) or the resourceType argument (guard)getResource / the argument
resource idsecond path segment (middleware) or req.params.id (guard)getResource / check in the handler
actioniamActionForMethod(req.method) (middleware) or the action argument (guard)getAction / the argument
scopegetScope(req) (middleware) or opts.scope (guard)either
environmentiamExtractEnvironment(req)getEnvironment

Forwarded-IP normalisation

The default getEnvironment is iamExtractEnvironment applied directly to the Express request, and it leaves environment.ip undefined. Express does have req.ip, but the helper ignores it without an opt-in: an integration may fill it from a platform header rather than a socket, and a condition keyed on a field the host never populated reads as a non-match, not an error - so a deny rule on environment.ip never fires and the request is allowed, silently, forever.

Opt in explicitly, after configuring Express's own trust proxy so req.ip is a value you believe:

// Behind exactly one trusted proxy.
app.set('trust proxy', 1)

app.use(iamAccessMiddleware(engine, {
  getEnvironment: (req) => iamExtractEnvironment(req, { trustProxy: true }),
}))

Under trustProxy the chain is req.ip, then the leftmost x-forwarded-for hop, then x-real-ip, all through the same normaliser. The full decision path is on the generic helpers page.

Error mapping

SituationWhere it is caughtResponse
getUserId returns a non-string, '', or a blank stringiamIsSubjectId, inside the try401 { error: 'Unauthorized' }, not overridable
engine.can() returns falseafter the callonDenied, default 403 { error: 'Forbidden' }
any extractor throws, getUserId includedtry around the whole bodyonError, default 500 { error: 'Internal server error' }
engine.can() rejectssame tryonError

A blank subject id is refused here, not by the engine. engine.can guards length === 0, not trim(), so ' ' is a perfectly good cache key to it and any assignment stored under that key would grant its permissions. iamIsSubjectId is the only layer that closes that, and it answers 401 rather than a plain 403.

engine.can() does not throw on an authorisation failure: it catches adapter and policy errors, routes them to the engine's onError hook, and returns false. The 500 paths above are for your own callbacks. See engine methods.

Admin router

iamAdminRouter returns a factory that takes the Express Router constructor, so nothing imports Express at runtime.

export function iamAdminRouter<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  opts: IamExpress.IAdminRouterOptions,
): (Router: () => ExpressRouterLike) => ExpressRouterLike
import { Router } from 'express'
import rateLimit from 'express-rate-limit'

const adminLimiter = rateLimit({ windowMs: 60_000, max: 30 })

app.use(
  '/api/access-admin',
  adminLimiter,
  iamAdminRouter(engine, {
    authorize: (req) => req.user?.role === 'platform-admin',
    onAdminMutation: (event) => auditLog.write(event),
    redactPath: (p) => p.replace(/\/[^/]+$/, '/:id'),
  })(Router),
)
EndpointEngine callAudit action / targettargetId
GET /policiesadmin.listPolicies()none (reads never audit)
GET /rolesadmin.listRoles()none
PUT /policiesadmin.savePolicy(req.body)replace / policyreq.body.id
PUT /rolesadmin.saveRole(req.body)replace / rolereq.body.id
POST /subjects/:id/rolesadmin.assignRole(id, body.roleId, body.scope)create / role-assignmentreq.params.id
DELETE /subjects/:id/roles/:roleIdadmin.revokeRole(id, roleId)delete / role-assignmentreq.params.id

Mutations write req.method and req.path ?? req.url ?? '' into the audit event; all four adapters fill targetId from the document's id for policy and role writes.

Options

OptionTypeDefaultMeaning
authorize(req) => unknown | Promise<unknown>requiredRuns on every endpoint, read and write. A falsy return (false, 0, '', null, undefined, NaN) is 401; a throw is 500. Return the actor itself, not true see below.
csrfCheck((req) => boolean) | falseiamDefaultCsrfCheckRuns on every admin request, reads included. false disables the phase.
onUnauthorized(req, res) => void401 { error: 'Unauthorized' }Replaces the falsy-authorize response.
onError(err, req, res) => void500 { error: 'Internal server error' }Same three-argument shape as the gates.
onAdminMutationIamAdminAudit.HooknoneFire-and-forget, fires on success and failure, never on GET.
redactPath(path: string) => stringidentityRewrites event.path before the hook.
onAuditHookError(err, event) => voidconsole.errorSink for a throwing hook.
includeErrorMessagebooleanfalseevent.error becomes err.message instead of the class name.

The last four are the shared IamAdminAudit.IOptions; their exact semantics, the event shape, and the CSRF predicate are documented once on the generic helpers page.

API reference

ExportKindPurpose
iamAccessMiddleware(engine, opts?)functionBlanket middleware with inferred action and resource
iamGuard(engine, action, resourceType, opts?)functionPer-route middleware with a fixed action and resource
iamAdminRouter(engine, opts)function(Router) => router factory for the six admin endpoints
IamExpress.IOptions<TScope>interfaceOptions for both gates
IamExpress.IAdminAuthorizetype(req) => boolean | Promise<boolean>
IamExpress.IAdminRouterOptionsinterfaceauthorize plus onUnauthorized, onError, and IamAdminAudit.IOptions
import type { IamExpress } from '@gentleduck/iam/server/express'

const opts: IamExpress.IOptions<'org-acme' | 'org-globex'> = {
  getUserId: (req) => req.user?.id ?? null,
  getScope: (req) => (req.params?.org === 'acme' ? 'org-acme' : undefined),
}

const adminAuth: IamExpress.IAdminAuthorize = (req) => req.user?.role === 'platform-admin'

The namespace is type-only and costs nothing at runtime.

Gotchas

  • Neither gate's onError receives next. Resuming a request whose decision could not be computed is a fail-open, so the option to do it was removed.
  • Nothing in this module ever answers 404. Every refusal it produces is 400, 401, 403 or 500; a 404 on an admin path comes from your own routing table.
  • The guard reads req.params.id only. For :postId, rename the parameter, use iamAccessMiddleware with a custom getResource, or check in the handler.
  • Path-derived resource types are plural when your URLs are plural: /posts/42 checks posts, not post. Name resources after URL segments or supply getResource.
  • Both gates pass attributes: {}. Ownership and status conditions must be evaluated in the handler against the loaded record.
  • iamAdminRouter is a factory of a factory: iamAdminRouter(engine, opts) returns a function you still have to call with Router.
  • The audit event.path for POST /subjects/:id/roles contains the expanded subject id. Pass redactPath when the audit sink is outside your trust boundary.

See also