express
Express middleware, per-route guard, and admin router for duck-iam. No runtime dependency on Express.
Install
import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'
No runtime dependency on Express - the integration is typed against minimal req/res/next interfaces and works with Express 4, Express 5, and any compatible router.
Global middleware
Apply access control to every route under a path prefix.
import { accessMiddleware } from '@gentleduck/iam/server/express'
import { IAM_METHOD_ACTION_MAP } from '@gentleduck/iam/server/generic'
const middleware = accessMiddleware(engine, {
// Extract user ID from your auth layer (passport, jwt, etc.)
getUserId: (req) => req.user?.id,
// Map HTTP method to action (defaults to IAM_METHOD_ACTION_MAP)
getAction: (req) => IAM_METHOD_ACTION_MAP[req.method],
// Infer resource from the URL path
getResource: (req) => {
const parts = req.path.split('/').filter(Boolean)
return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
},
// Optional: extract scope from headers or query
getScope: (req) => req.headers['x-org-id'] as string | undefined,
// Optional: extract environment context (IP, user agent, timestamp)
getEnvironment: (req) => ({
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: Date.now(),
}),
// Custom denial response
onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),
// Custom error handler
onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})
app.use('/api', middleware)
Per-route guard
Use guard on individual routes when the action and resource are known at definition time.
import { guard } from '@gentleduck/iam/server/express'
// Basic guard - action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
// Only reached if engine.can() returns true
res.json({ deleted: true })
})
// Scoped guard - restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
res.json({ created: true })
})
// With custom environment
app.patch(
'/posts/:id',
guard(engine, 'update', 'post', {
getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
}),
handler,
)
// Custom user ID extraction
app.get(
'/reports',
guard(engine, 'read', 'report', {
getUserId: (req) => req.headers['x-api-key'] as string,
}),
handler,
)
Error handling differs between the two:
guardcatches errors and passes them tonext(err)- they reach Express's error-handling middleware.accessMiddlewarecatches errors and calls theonErroroption - defaults to a 500 JSON response.
guard() helper reads the resource instance ID from req.params.id. If your
route uses a different param name such as :postId, either use accessMiddleware() with a
custom getResource, or run engine.can() / engine.check() yourself inside the handler
once you have the real resource ID and attributes.Admin router
Mount a pre-built admin API for managing policies, roles, and assignments. The authorize callback is required - the factory throws at boot if it's missing.
import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
const createRouter = adminRouter(engine, {
authorize: (req) => req.user?.role === 'platform-admin',
})
app.use('/api/access-admin', createRouter(() => express.Router()))
Every endpoint (read + write) runs authorize(req) before dispatching. Falsy returns produce 401 Unauthorized; thrown errors route through onError (default 500).
CSRF protection (default-on)
Mutation handlers (PUT/POST/DELETE) run a Sec-Fetch-Site check by default
(SEC-103 / CAVEAT-2). Browsers populate the header automatically; cross-site
form posts are rejected with 403, same-site / same-origin requests pass.
Non-browser callers (no header) pass - they must be gated by bearer / mTLS.
// Default - cookie-auth admin UIs get CSRF protection with no opt-in.
adminRouter(engine, { authorize })
// Server-to-server bearer/mTLS API - disable the check entirely.
adminRouter(engine, { authorize, csrfCheck: false })
// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
adminRouter(engine, {
authorize,
csrfCheck: (req) => ADMIN_ORIGINS.has(req.headers.origin as string),
})
subjectId + roleId only. If you store both global and
scoped assignments for the same role and you need to revoke only one scoped assignment, expose a
custom route that calls engine.admin.revokeRole(subjectId, roleId, scope) explicitly.This exposes:
| Method | Path | Description |
|---|---|---|
| GET | /policies | List all policies |
| GET | /roles | List all roles |
| PUT | /policies | Create or update a policy |
| PUT | /roles | Create or update a role |
| POST | /subjects/:id/roles | Assign a role to a subject |
| DELETE | /subjects/:id/roles/:roleId | Revoke a role from a subject |
adminRouter options:
| Option | Type | Default | Description |
|---|---|---|---|
authorize | (req) -> boolean | Promise<boolean> | required | Per-request guard. Mounting unauthenticated is impossible. |
csrfCheck | ((req) -> boolean) | false | built-in Sec-Fetch-Site (SEC-103) | CSRF guard for mutations; false to disable |
onUnauthorized | (req, res) -> void | 401 JSON | Custom unauthorized response |
onError | (err, req, res) -> void | 500 JSON | Custom error handler |
Options reference
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (req) -> string or null | req.user?.id | Extract the subject ID from the request |
getAction | (req) -> string | HTTP method map | Map the request to an action |
getResource | (req) -> Resource | Infer from URL path | Map the request to a resource |
getEnvironment | (req) -> Environment | IP + user agent + timestamp | Extract environment context |
getScope | (req) -> string or undefined | undefined | Extract scope (e.g. org ID, team ID) |
onDenied | (req, res) -> void | 403 JSON | Custom denial response |
onError | (err, req, res, next) -> void | 500 JSON | Custom error handler |
The full options object conforms to Express.IOptions. The admin router options conform to Express.IAdminRouterOptions, and the required authorize callback signature is Express.IAdminAuthorize.
Types
All types live under the Express namespace at @gentleduck/iam/server/express. Type-only - zero bundle cost.
Express.IOptions- options foraccessMiddlewareandguard(identity, action, resource, scope, environment, and failure handlers).Express.IAdminAuthorize- signature of the requiredauthorizecallback foradminRouter.Express.IAdminRouterOptions- options foradminRouter(authorize,onUnauthorized,onError).
import type { Express } from '@gentleduck/iam/server/express'
const opts: Express.IOptions = {
getUserId: (req) => req.user?.id,
getScope: (req) => req.headers['x-org-id'] as string | undefined,
}
const adminAuth: Express.IAdminAuthorize = (req) => req.user?.role === 'platform-admin'
Deprecated bare aliases (IExpressOptions, IAdminAuthorize, IAdminRouterOptions) remain for back-compat and will be removed in 3.0.