server middleware
Protect your API routes with duck-iam server integrations for Express, NestJS, Hono, and Next.js App Router.
Overview
duck-iam ships server integrations for the most popular Node.js frameworks. Each integration follows the same pattern: wrap your engine around incoming requests, extract the user identity, map the HTTP method to an action, infer the resource from the route, and let the engine decide.
Every integration is a thin adapter over engine.can(). There is no runtime framework dependency -- duck-iam defines its own minimal type interfaces so you never pull in extra packages.
HTTP method mapping
All server integrations share a default method-to-action map:
const METHOD_ACTION_MAP = {
GET: 'read',
HEAD: 'read',
OPTIONS: 'read',
POST: 'create',
PUT: 'update',
PATCH: 'update',
DELETE: 'delete',
}const METHOD_ACTION_MAP = {
GET: 'read',
HEAD: 'read',
OPTIONS: 'read',
POST: 'create',
PUT: 'update',
PATCH: 'update',
DELETE: 'delete',
}You can override this per-route or globally through the getAction option.
Express
import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'
Global middleware
Apply access control to every route under a path prefix. The middleware extracts the user ID, action, and resource from each request, then calls engine.can().
import { accessMiddleware } from '@gentleduck/iam/server/express'
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 METHOD_ACTION_MAP)
getAction: (req) => 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)import { accessMiddleware } from '@gentleduck/iam/server/express'
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 METHOD_ACTION_MAP)
getAction: (req) => 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
For more granular control, use the guard function on individual routes. This is useful 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)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)The guard function catches errors and passes them to Express's next(err), so they
reach your error-handling middleware. The accessMiddleware catches errors and calls
the onError option.
Admin router
Mount a pre-built admin API for managing policies, roles, and assignments at runtime.
import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
const createRouter = adminRouter(engine)
app.use('/api/access-admin', createRouter(() => express.Router()))import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
const createRouter = adminRouter(engine)
app.use('/api/access-admin', createRouter(() => express.Router()))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 |
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 |
NestJS
import { nestAccessGuard, Authorize, createTypedAuthorize, createEngineProvider } from '@gentleduck/iam/server/nest'
Guard factory
Create a NestJS-compatible guard function. It reads metadata from the @Authorize decorator on each handler to determine the required action and resource.
import { nestAccessGuard } from '@gentleduck/iam/server/nest'
const canAccess = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id ?? req.user?.sub,
getScope: (req) => req.headers['x-org-id'] as string | undefined,
getResourceId: (req) => req.params?.id,
onError: (err, req) => false,
})
// Register as a global guard in your module:
// APP_GUARD -> { provide: APP_GUARD, useValue: { canActivate: canAccess } }import { nestAccessGuard } from '@gentleduck/iam/server/nest'
const canAccess = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id ?? req.user?.sub,
getScope: (req) => req.headers['x-org-id'] as string | undefined,
getResourceId: (req) => req.params?.id,
onError: (err, req) => false,
})
// Register as a global guard in your module:
// APP_GUARD -> { provide: APP_GUARD, useValue: { canActivate: canAccess } }Handlers without an @Authorize decorator are allowed by default -- the guard only activates when metadata is present.
The Authorize decorator
Mark controller methods with their required permissions.
import { Authorize } from '@gentleduck/iam/server/nest'
@Controller('posts')
export class PostsController {
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return this.postsService.delete(id)
}
@Post()
@Authorize({ action: 'create', resource: 'post' })
async createPost(@Body() dto: CreatePostDto) {
return this.postsService.create(dto)
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize({ infer: true })
async listPosts() {
return this.postsService.list()
}
}import { Authorize } from '@gentleduck/iam/server/nest'
@Controller('posts')
export class PostsController {
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return this.postsService.delete(id)
}
@Post()
@Authorize({ action: 'create', resource: 'post' })
async createPost(@Body() dto: CreatePostDto) {
return this.postsService.create(dto)
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize({ infer: true })
async listPosts() {
return this.postsService.list()
}
}Scoped authorization
Attach a scope to restrict access within a specific context (e.g. a particular organization).
@Authorize({ action: 'manage', resource: 'billing', scope: 'admin' })
async updateBilling() { ... }@Authorize({ action: 'manage', resource: 'billing', scope: 'admin' })
async updateBilling() { ... }If no scope is set on the decorator, the guard falls back to the getScope option from the guard factory.
Type-safe decorator
Use createTypedAuthorize to constrain the decorator to your application's exact action, resource, and scope types. Typos become compile-time errors.
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'user' | 'billing' | 'report'
type Scope = 'admin' | 'member'
const Auth = createTypedAuthorize<Action, Resource, Scope>()
// This compiles:
@Auth({ action: 'delete', resource: 'post' })
// This is a compile error -- "craete" is not in Action:
@Auth({ action: 'craete', resource: 'post' })import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'user' | 'billing' | 'report'
type Scope = 'admin' | 'member'
const Auth = createTypedAuthorize<Action, Resource, Scope>()
// This compiles:
@Auth({ action: 'delete', resource: 'post' })
// This is a compile error -- "craete" is not in Action:
@Auth({ action: 'craete', resource: 'post' })Engine provider
Register the engine as a NestJS provider for dependency injection.
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
@Module({
providers: [
createEngineProvider(() => new Engine({ adapter })),
{
provide: APP_GUARD,
useFactory: (engine) => ({ canActivate: nestAccessGuard(engine) }),
inject: [ACCESS_ENGINE_TOKEN],
},
],
})
export class AppModule {}import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
@Module({
providers: [
createEngineProvider(() => new Engine({ adapter })),
{
provide: APP_GUARD,
useFactory: (engine) => ({ canActivate: nestAccessGuard(engine) }),
inject: [ACCESS_ENGINE_TOKEN],
},
],
})
export class AppModule {}Options reference
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (req) -> string or null | req.user?.id ?? req.user?.sub | Extract the subject ID |
getEnvironment | (req) -> Environment | IP + user agent + timestamp | Extract environment context |
getResourceId | (req) -> string or undefined | req.params?.id | Extract the resource instance ID |
getScope | (req) -> string or undefined | undefined | Fallback scope when decorator has none |
onError | (err, req) -> boolean | () => false | Return true to allow on error, false to deny |
Hono
import { accessMiddleware, guard } from '@gentleduck/iam/server/hono'
Global middleware
Apply access control to all routes under a path pattern.
import { accessMiddleware } from '@gentleduck/iam/server/hono'
app.use('/api/*', accessMiddleware(engine, {
getUserId: (c) => c.get('userId') as string ?? c.req.header('x-user-id'),
getScope: (c) => c.req.header('x-org-id'),
onDenied: (c) => c.json({ error: 'Forbidden' }, 403),
onError: (err, c) => c.json({ error: 'Internal server error' }, 500),
}))import { accessMiddleware } from '@gentleduck/iam/server/hono'
app.use('/api/*', accessMiddleware(engine, {
getUserId: (c) => c.get('userId') as string ?? c.req.header('x-user-id'),
getScope: (c) => c.req.header('x-org-id'),
onDenied: (c) => c.json({ error: 'Forbidden' }, 403),
onError: (err, c) => c.json({ error: 'Internal server error' }, 500),
}))The middleware reads the user ID from context or headers by default. If no user ID is found, it returns a 401 response immediately.
Per-route guard
Guard individual routes with fixed action and resource types.
import { guard } from '@gentleduck/iam/server/hono'
app.delete(
'/posts/:id',
guard(engine, 'delete', 'post'),
async (c) => {
// Only reached if the user can delete posts
return c.json({ deleted: true })
}
)
app.post(
'/admin/users',
guard(engine, 'manage', 'user', { scope: 'admin' }),
async (c) => {
return c.json({ created: true })
}
)import { guard } from '@gentleduck/iam/server/hono'
app.delete(
'/posts/:id',
guard(engine, 'delete', 'post'),
async (c) => {
// Only reached if the user can delete posts
return c.json({ deleted: true })
}
)
app.post(
'/admin/users',
guard(engine, 'manage', 'user', { scope: 'admin' }),
async (c) => {
return c.json({ created: true })
}
)The guard automatically reads c.req.param('id') as the resource ID when available.
Environment extraction
Hono's default environment extractor reads:
cf-connecting-iporx-forwarded-forfor the IP addressuser-agentfor the user agent stringDate.now()for the timestamp
Override with the getEnvironment option if you need custom fields.
Options reference
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (c) -> string or null | Context userId or x-user-id header | Extract the subject ID |
getAction | (c) -> string | HTTP method map | Map the request to an action |
getResource | (c) -> Resource | Infer from URL path | Map the request to a resource |
getEnvironment | (c) -> Environment | CF/forwarded IP + user agent + timestamp | Extract environment context |
getScope | (c) -> string or undefined | undefined | Extract scope from the request |
onDenied | (c) -> Response | 403 JSON | Custom denial response |
onError | (err, c) -> Response | 500 JSON | Custom error handler |
Next.js App Router
import { withAccess, checkAccess, getPermissions, createNextMiddleware } from '@gentleduck/iam/server/next'
The Next.js integration covers four use cases: route handler protection, server component checks, permission map generation for client hydration, and edge middleware.
Route handler wrapper
Protect App Router route handlers (the functions exported as GET, POST, DELETE, etc.).
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
// Only reached if the user can delete this post
await deletePost(id)
return Response.json({ deleted: true })
}
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
getUserId: async (req) => {
const session = await auth()
return session?.user?.id ?? null
},
})import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
// Only reached if the user can delete this post
await deletePost(id)
return Response.json({ deleted: true })
}
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
getUserId: async (req) => {
const session = await auth()
return session?.user?.id ?? null
},
})The wrapper automatically reads params.id as the resource instance ID.
Server component checks
Check a single permission inside a React Server Component or server action.
import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
export default async function PostPage({ params }: { params: { id: string } }) {
const { id } = params
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
return (
<article>
<h1>Post {id}</h1>
{canEdit && <EditButton />}
{canDelete && <DeleteButton />}
</article>
)
}import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
export default async function PostPage({ params }: { params: { id: string } }) {
const { id } = params
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
return (
<article>
<h1>Post {id}</h1>
{canEdit && <EditButton />}
{canDelete && <DeleteButton />}
</article>
)
}Permission map generation
Generate a PermissionMap on the server and pass it to the client. This is the recommended pattern for client-side access control -- evaluate permissions once on the server and hydrate the client with the results.
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
const userId = session?.user?.id
const permissions = userId
? await getPermissions(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'team' },
{ action: 'read', resource: 'analytics' },
])
: {}
return (
<html>
<body>
<AccessProvider permissions={permissions}>
{children}
</AccessProvider>
</body>
</html>
)
}import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
const userId = session?.user?.id
const permissions = userId
? await getPermissions(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'team' },
{ action: 'read', resource: 'analytics' },
])
: {}
return (
<html>
<body>
<AccessProvider permissions={permissions}>
{children}
</AccessProvider>
</body>
</html>
)
}Edge middleware
Protect routes at the edge before they reach your application code. Define rules that map URL patterns to required permissions.
// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
const checkAccess = createNextMiddleware(engine, {
getUserId: async (req) => {
// Extract user ID from cookie/token at the edge
const token = req.headers.get('authorization')?.replace('Bearer ', '')
return token ? decodeUserId(token) : null
},
rules: [
{ pattern: '/api/admin', resource: 'admin', action: 'manage' },
{ pattern: '/api/posts', resource: 'post' }, // action inferred from HTTP method
{ pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
],
})
export async function middleware(req: Request) {
const denied = await checkAccess(req)
if (denied) return denied // 401 or 403 Response
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
const checkAccess = createNextMiddleware(engine, {
getUserId: async (req) => {
// Extract user ID from cookie/token at the edge
const token = req.headers.get('authorization')?.replace('Bearer ', '')
return token ? decodeUserId(token) : null
},
rules: [
{ pattern: '/api/admin', resource: 'admin', action: 'manage' },
{ pattern: '/api/posts', resource: 'post' }, // action inferred from HTTP method
{ pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
],
})
export async function middleware(req: Request) {
const denied = await checkAccess(req)
if (denied) return denied // 401 or 403 Response
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}When no rule matches a request path, the middleware returns null and the request passes through.
Options reference
withAccess options:
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (req) -> string or null or Promise | x-user-id header | Extract the subject ID |
getEnvironment | (req) -> Environment | IP + user agent + timestamp | Extract environment context |
scope | string | undefined | Fixed scope for this handler |
onError | (err, req) -> Response | 500 JSON | Custom error handler |
createNextMiddleware options:
| Option | Type | Default | Description |
|---|---|---|---|
rules | Array | -- | Route rules (see below). Required. |
getUserId | (req) -> string or null or Promise | -- | Extract the subject ID. Required. |
onError | (err, req) -> Response | 500 JSON | Custom error handler |
Rule format (each entry in rules):
| Field | Type | Required | Description |
|---|---|---|---|
pattern | string or RegExp | yes | URL path prefix (string) or regex pattern |
resource | string | yes | Resource type for matched routes |
action | string | no | Fixed action (defaults to HTTP method map) |
scope | string | no | Required scope for matched routes |
Common patterns
Combining global and per-route protection
Use global middleware for broad protection, then layer per-route guards for specific endpoints that need different rules.
// Express example
app.use('/api', accessMiddleware(engine, { getUserId: (req) => req.user?.id }))
// Override for a specific route that needs a different scope
app.delete('/api/admin/users/:id',
guard(engine, 'manage', 'user', { scope: 'admin' }),
deleteUserHandler
)// Express example
app.use('/api', accessMiddleware(engine, { getUserId: (req) => req.user?.id }))
// Override for a specific route that needs a different scope
app.delete('/api/admin/users/:id',
guard(engine, 'manage', 'user', { scope: 'admin' }),
deleteUserHandler
)Custom action mapping
Override the default HTTP method mapping when your API uses non-standard patterns.
const middleware = accessMiddleware(engine, {
getUserId: (req) => req.user?.id,
getAction: (req) => {
// POST /api/posts/:id/publish -> "publish" action
if (req.method === 'POST' && req.path.endsWith('/publish')) {
return 'publish'
}
return METHOD_ACTION_MAP[req.method] ?? 'read'
},
})const middleware = accessMiddleware(engine, {
getUserId: (req) => req.user?.id,
getAction: (req) => {
// POST /api/posts/:id/publish -> "publish" action
if (req.method === 'POST' && req.path.endsWith('/publish')) {
return 'publish'
}
return METHOD_ACTION_MAP[req.method] ?? 'read'
},
})Error handling
All server integrations wrap engine.can() in try/catch. If the engine throws (adapter
failure, misconfigured policy, etc.), the default behavior is:
- Express middleware: calls
onError(err, req, res, next)-- defaults to 500 JSON. - Express guard: passes the error to
next(err)for Express error middleware. - Hono: returns
onError(err, c)-- defaults to 500 JSON. - NestJS: calls
onError(err, req)-- defaults to returningfalse(deny). - Next.js: returns
onError(err, req)-- defaults to 500 JSON.
The engine itself never throws from authorize() -- it catches internal errors, calls the
onError hook, and returns a deny decision. The server integration try/catch is an extra
safety net for edge cases like adapter connection failures during subject resolution.
Server-driven client permissions
The recommended end-to-end pattern:
- Generate a
PermissionMapon the server usinggetPermissionsorengine.permissions() - Pass the map to your client via props, response body, or server-rendered HTML
- Hydrate an
AccessProvider(React), plugin (Vue), orAccessClient(vanilla) on the client - All client-side checks are instant lookups -- no additional network requests
// Server: generate the permission map
const checks = [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'team' },
] as const
const permissions = await engine.permissions(userId, checks)
// Client: hydrate and use
// See the client libraries documentation for framework-specific setup// Server: generate the permission map
const checks = [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'team' },
] as const
const permissions = await engine.permissions(userId, checks)
// Client: hydrate and use
// See the client libraries documentation for framework-specific setup