chapter 6: server integration
Add authorization middleware to your backend. Protect Express, Hono, NestJS, or Next.js routes with duck-iam guards and generate permission maps for the client.
Goal
BlogDuck has a working authorization engine. Now protect actual HTTP endpoints. Pick the framework you use.
How It Works
All server integrations share the same pattern:
- Extract the user ID from the request (JWT, session, header)
- Determine the action (from HTTP method) and resource (from URL path)
- Call
engine.can()with the extracted context - Allow or deny the request
HTTP Method to Action Mapping
duck-iam maps HTTP methods to actions automatically via METHOD_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',
}URL Path to Resource Mapping
The default resource extraction parses the URL path:
/api/posts/123
^ ^
| +-- resourceId: '123'
+-- resourceType: 'posts'
The first path segment after a base path is the resource type, the second is the resource ID.
Customize this with the getResource callback.
Environment Extraction
The extractEnvironment() helper reads standard headers:
import { extractEnvironment } from '@gentleduck/iam/server/generic'
const env = extractEnvironment(req)
// {
// ip: '192.168.1.1', // from req.ip or x-forwarded-for or x-real-ip
// userAgent: 'Mozilla/...', // from user-agent header
// timestamp: 1708300000000, // Date.now()
// }import { extractEnvironment } from '@gentleduck/iam/server/generic'
const env = extractEnvironment(req)
// {
// ip: '192.168.1.1', // from req.ip or x-forwarded-for or x-real-ip
// userAgent: 'Mozilla/...', // from user-agent header
// timestamp: 1708300000000, // Date.now()
// }Generic Helpers
Framework-agnostic utilities that work with any server.
generatePermissionMap()
Generate a PermissionMap for the client (Chapter 7):
import { generatePermissionMap } from '@gentleduck/iam/server/generic'
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
{ action: 'manage', resource: 'user', scope: 'acme' },
])
// { 'create:post': true, 'update:post:post-1': true, ... }import { generatePermissionMap } from '@gentleduck/iam/server/generic'
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
{ action: 'manage', resource: 'user', scope: 'acme' },
])
// { 'create:post': true, 'update:post:post-1': true, ... }Pass this map to the client AccessProvider for permission-based UI rendering.
createSubjectCan()
Create a reusable can function bound to a user:
import { createSubjectCan } from '@gentleduck/iam/server/generic'
const can = createSubjectCan(engine, userId)
if (await can('delete', 'post', 'post-1')) {
// delete the post
}
if (await can('manage', 'dashboard')) {
// show admin panel
}
// With scope
if (await can('manage', 'user', undefined, 'acme')) {
// manage users in acme
}import { createSubjectCan } from '@gentleduck/iam/server/generic'
const can = createSubjectCan(engine, userId)
if (await can('delete', 'post', 'post-1')) {
// delete the post
}
if (await can('manage', 'dashboard')) {
// show admin panel
}
// With scope
if (await can('manage', 'user', undefined, 'acme')) {
// manage users in acme
}Engine Mode on the Server
All server integrations call engine.can() under the hood, which always returns a boolean
regardless of engine mode. Middleware, guards, and route handlers work identically in both
'development' and 'production' mode.
Use mode: 'production' for maximum throughput. Production mode skips Decision object
allocation, timing, and hooks, giving roughly 2x faster:
import { Engine } from '@gentleduck/iam'
import { adapter } from './adapter'
export const engine = new Engine({
adapter,
mode: 'production', // recommended for servers -- ~2x faster, no Decision overhead
})import { Engine } from '@gentleduck/iam'
import { adapter } from './adapter'
export const engine = new Engine({
adapter,
mode: 'production', // recommended for servers -- ~2x faster, no Decision overhead
})Temporarily switch back to 'development' mode for decision traces. Since engine.can()
returns boolean either way, the rest of your code stays the same.
Express
Global middleware
import express from 'express'
import { engine } from './access' // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/express'
const app = express()
app.use(express.json())
app.use(accessMiddleware(engine, {
getUserId: (req) => req.headers['x-user-id'] as string,
getScope: (req) => req.headers['x-organization'] as string,
}))import express from 'express'
import { engine } from './access' // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/express'
const app = express()
app.use(express.json())
app.use(accessMiddleware(engine, {
getUserId: (req) => req.headers['x-user-id'] as string,
getScope: (req) => req.headers['x-organization'] as string,
}))Missing user ID returns 401. Denied permission returns 403.
Full options:
interface ExpressOptions {
getUserId?: (req) => string | null // default: req.user?.id
getResource?: (req) => Resource // default: parse from URL
getAction?: (req) => string // default: METHOD_ACTION_MAP
getEnvironment?: (req) => Environment // default: extractEnvironment()
getScope?: (req) => string | undefined // default: none
onDenied?: (req, res) => void // default: res.status(403).json(...)
onError?: (err, req, res, next) => void // default: res.status(500).json(...)
}interface ExpressOptions {
getUserId?: (req) => string | null // default: req.user?.id
getResource?: (req) => Resource // default: parse from URL
getAction?: (req) => string // default: METHOD_ACTION_MAP
getEnvironment?: (req) => Environment // default: extractEnvironment()
getScope?: (req) => string | undefined // default: none
onDenied?: (req, res) => void // default: res.status(403).json(...)
onError?: (err, req, res, next) => void // default: res.status(500).json(...)
}Per-route guards
import { guard } from '@gentleduck/iam/server/express'
// Explicit action and resource
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (req) => req.headers['x-user-id'] as string,
}),
(req, res) => {
res.json({ deleted: req.params.id })
}
)
// With a fixed scope
app.post('/api/admin/users',
guard(engine, 'manage', 'user', {
getUserId: (req) => req.headers['x-user-id'] as string,
scope: 'admin',
}),
(req, res) => {
res.json({ created: true })
}
)import { guard } from '@gentleduck/iam/server/express'
// Explicit action and resource
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (req) => req.headers['x-user-id'] as string,
}),
(req, res) => {
res.json({ deleted: req.params.id })
}
)
// With a fixed scope
app.post('/api/admin/users',
guard(engine, 'manage', 'user', {
getUserId: (req) => req.headers['x-user-id'] as string,
scope: 'admin',
}),
(req, res) => {
res.json({ created: true })
}
)guard() takes an explicit action and resource. The resource ID is auto-extracted from req.params.id.
Admin router
import { Router } from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
app.use('/api/access-admin', adminRouter(engine)(() => Router()))import { Router } from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
app.use('/api/access-admin', adminRouter(engine)(() => Router()))Exposed endpoints:
| Method | Path | Description | Body |
|---|---|---|---|
| GET | /policies | List all policies | |
| GET | /roles | List all roles | |
| PUT | /policies | Save/update a policy | Policy object |
| PUT | /roles | Save/update a role | Role object |
| POST | /subjects/:id/roles | Assign role to subject | { roleId, scope? } |
| DELETE | /subjects/:id/roles/:roleId | Revoke role from subject |
Protect the admin router with your own auth middleware. Never expose it to unauthenticated users.
To revoke one scoped assignment without affecting the same role in other scopes,
call engine.admin.revokeRole(subjectId, roleId, scope) directly.
Hono
Global middleware
import { Hono } from 'hono'
import { engine } from './access' // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/hono'
const app = new Hono()
app.use('*', accessMiddleware(engine, {
getUserId: (c) => c.get('userId') || c.req.header('x-user-id'),
getScope: (c) => c.req.header('x-organization'),
}))import { Hono } from 'hono'
import { engine } from './access' // mode: 'production' recommended
import { accessMiddleware } from '@gentleduck/iam/server/hono'
const app = new Hono()
app.use('*', accessMiddleware(engine, {
getUserId: (c) => c.get('userId') || c.req.header('x-user-id'),
getScope: (c) => c.req.header('x-organization'),
}))The default getUserId checks c.get('userId') first (set by your auth middleware),
then falls back to the x-user-id header.
For Cloudflare Workers, the IP is extracted from cf-connecting-ip (then x-forwarded-for as fallback).
Full options:
interface HonoOptions {
getUserId?: (c) => string | null
getResource?: (c) => Resource
getAction?: (c) => string
getEnvironment?: (c) => Environment
getScope?: (c) => string | undefined
onDenied?: (c) => Response
onError?: (err, c) => Response
}interface HonoOptions {
getUserId?: (c) => string | null
getResource?: (c) => Resource
getAction?: (c) => string
getEnvironment?: (c) => Environment
getScope?: (c) => string | undefined
onDenied?: (c) => Response
onError?: (err, c) => Response
}Per-route guards
import { guard } from '@gentleduck/iam/server/hono'
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (c) => c.get('userId'),
}),
async (c) => {
return c.json({ deleted: c.req.param('id') })
}
)import { guard } from '@gentleduck/iam/server/hono'
app.delete('/api/posts/:id',
guard(engine, 'delete', 'post', {
getUserId: (c) => c.get('userId'),
}),
async (c) => {
return c.json({ deleted: c.req.param('id') })
}
)NestJS
Create the access module
import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access' // mode: 'production' recommended
@Module({
providers: [
createEngineProvider(() => engine),
],
exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access' // mode: 'production' recommended
@Module({
providers: [
createEngineProvider(() => engine),
],
exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}createEngineProvider() returns a NestJS provider that makes the engine available
via dependency injection using the ACCESS_ENGINE_TOKEN injection token.
Create the access guard
import { CanActivate, ExecutionContext, Injectable, Inject } from '@nestjs/common'
import { nestAccessGuard, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import type { Engine } from '@gentleduck/iam'
@Injectable()
export class AccessGuard implements CanActivate {
private check: ReturnType<typeof nestAccessGuard>
constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine) {
this.check = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id || req.user?.sub,
getScope: (req) => req.headers['x-organization'],
getResourceId: (req) => req.params?.id,
})
}
async canActivate(context: ExecutionContext): Promise<boolean> {
return this.check(context)
}
}import { CanActivate, ExecutionContext, Injectable, Inject } from '@nestjs/common'
import { nestAccessGuard, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import type { Engine } from '@gentleduck/iam'
@Injectable()
export class AccessGuard implements CanActivate {
private check: ReturnType<typeof nestAccessGuard>
constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine) {
this.check = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id || req.user?.sub,
getScope: (req) => req.headers['x-organization'],
getResourceId: (req) => req.params?.id,
})
}
async canActivate(context: ExecutionContext): Promise<boolean> {
return this.check(context)
}
}Full options:
interface NestGuardOptions {
getUserId?: (req) => string | null // default: req.user?.id or req.user?.sub
getEnvironment?: (req) => Environment // default: extractEnvironment()
getResourceId?: (req) => string | undefined // default: req.params?.id
getScope?: (req) => string | undefined // default: none
onError?: (err, req) => boolean // default: return false (deny)
}interface NestGuardOptions {
getUserId?: (req) => string | null // default: req.user?.id or req.user?.sub
getEnvironment?: (req) => Environment // default: extractEnvironment()
getResourceId?: (req) => string | undefined // default: req.params?.id
getScope?: (req) => string | undefined // default: none
onError?: (err, req) => boolean // default: return false (deny)
}Use the @Authorize decorator
import { Controller, Delete, Get, Post, Param, UseGuards } from '@nestjs/common'
import { Authorize } from '@gentleduck/iam/server/nest'
import { AccessGuard } from '../access/access.guard'
@Controller('posts')
@UseGuards(AccessGuard)
export class PostsController {
// Explicit action and resource
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return { deleted: id }
}
// With scope
@Post('admin')
@Authorize({ action: 'manage', resource: 'post', scope: 'admin' })
async adminAction() {
return { success: true }
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize() // infer: true by default
async listPosts() {
return []
}
}import { Controller, Delete, Get, Post, Param, UseGuards } from '@nestjs/common'
import { Authorize } from '@gentleduck/iam/server/nest'
import { AccessGuard } from '../access/access.guard'
@Controller('posts')
@UseGuards(AccessGuard)
export class PostsController {
// Explicit action and resource
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return { deleted: id }
}
// With scope
@Post('admin')
@Authorize({ action: 'manage', resource: 'post', scope: 'admin' })
async adminAction() {
return { success: true }
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize() // infer: true by default
async listPosts() {
return []
}
}When @Authorize() uses infer: true (the default), action is inferred from the HTTP method
via METHOD_ACTION_MAP and resource is inferred from the route path (last non-parameter segment).
If no @Authorize decorator is present, the guard allows the request through.
AuthorizeMeta interface:
interface AuthorizeMeta {
action?: string // explicit action
resource?: string // explicit resource type
scope?: string // explicit scope
infer?: boolean // infer from HTTP method + route (default: true)
}interface AuthorizeMeta {
action?: string // explicit action
resource?: string // explicit resource type
scope?: string // explicit scope
infer?: boolean // infer from HTTP method + route (default: true)
}Type-safe decorator
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type AppAction = 'read' | 'create' | 'update' | 'delete' | 'manage'
type AppResource = 'post' | 'comment' | 'user' | 'dashboard'
type AppScope = 'acme' | 'globex'
export const Authorize = createTypedAuthorize<AppAction, AppResource, AppScope>()
// Now typos are compile errors:
// @Authorize({ action: 'delet', resource: 'post' }) // TypeScript errorimport { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type AppAction = 'read' | 'create' | 'update' | 'delete' | 'manage'
type AppResource = 'post' | 'comment' | 'user' | 'dashboard'
type AppScope = 'acme' | 'globex'
export const Authorize = createTypedAuthorize<AppAction, AppResource, AppScope>()
// Now typos are compile errors:
// @Authorize({ action: 'delet', resource: 'post' }) // TypeScript errorNext.js (App Router)
Protect API route handlers
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access' // mode: 'production' recommended
export const DELETE = withAccess(engine, 'delete', 'post',
async (req, { params }) => {
const { id } = await params
return Response.json({ deleted: id })
},
{
getUserId: (req) => req.headers.get('x-user-id'),
scope: 'acme',
}
)import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access' // mode: 'production' recommended
export const DELETE = withAccess(engine, 'delete', 'post',
async (req, { params }) => {
const { id } = await params
return Response.json({ deleted: id })
},
{
getUserId: (req) => req.headers.get('x-user-id'),
scope: 'acme',
}
)WithAccessOptions:
interface WithAccessOptions {
getUserId?: (req: Request) => string | null | Promise<string | null>
getEnvironment?: (req: Request) => Environment
scope?: string
onError?: (err: Error, req: Request) => Response
}interface WithAccessOptions {
getUserId?: (req: Request) => string | null | Promise<string | null>
getEnvironment?: (req: Request) => Environment
scope?: string
onError?: (err: Error, req: Request) => Response
}getUserId can be async, useful for reading from cookies or JWT.
Check permissions in Server Components
import { checkAccess, getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
export default async function PostPage({ params }) {
const { id } = await params
const userId = 'alice' // from your auth
// Single check
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
// Batch check for the client
const permissions = await getPermissions(engine, userId, [
{ action: 'update', resource: 'post', resourceId: id },
{ action: 'delete', resource: 'post', resourceId: id },
{ action: 'create', resource: 'comment' },
])
return (
<div>
<h1>Post {id}</h1>
{canDelete && <button>Delete</button>}
<ClientToolbar permissions={permissions} />
</div>
)
}import { checkAccess, getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
export default async function PostPage({ params }) {
const { id } = await params
const userId = 'alice' // from your auth
// Single check
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
// Batch check for the client
const permissions = await getPermissions(engine, userId, [
{ action: 'update', resource: 'post', resourceId: id },
{ action: 'delete', resource: 'post', resourceId: id },
{ action: 'create', resource: 'comment' },
])
return (
<div>
<h1>Post {id}</h1>
{canDelete && <button>Delete</button>}
<ClientToolbar permissions={permissions} />
</div>
)
}checkAccess() returns a boolean. getPermissions() returns a PermissionMap
for client-side hydration.
Next.js middleware for route-level protection
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
const checkAccess = createNextMiddleware(engine, {
rules: [
{ pattern: '/api/admin', resource: 'dashboard', action: 'manage' },
{ pattern: /^\/api\/posts\/\w+$/, resource: 'post' },
{ pattern: '/api/users', resource: 'user', scope: 'admin' },
],
getUserId: (req) => req.headers.get('x-user-id'),
})
export async function middleware(req: Request) {
const result = await checkAccess(req)
if (result) return result // 401 or 403
// No match or allowed -- continue
}
export const config = {
matcher: ['/api/:path*'],
}import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
const checkAccess = createNextMiddleware(engine, {
rules: [
{ pattern: '/api/admin', resource: 'dashboard', action: 'manage' },
{ pattern: /^\/api\/posts\/\w+$/, resource: 'post' },
{ pattern: '/api/users', resource: 'user', scope: 'admin' },
],
getUserId: (req) => req.headers.get('x-user-id'),
})
export async function middleware(req: Request) {
const result = await checkAccess(req)
if (result) return result // 401 or 403
// No match or allowed -- continue
}
export const config = {
matcher: ['/api/:path*'],
}Rule matching:
- String patterns use
path.startsWith(pattern), so/api/adminmatches/api/admin/users - Regex patterns use
pattern.test(path)for precise matching - First matching rule wins (order matters)
- No matching rule passes the request through
- Missing
actionis inferred from the HTTP method
Rule interface:
interface Rule {
pattern: string | RegExp // URL path pattern
resource: string // resource type
action?: string // optional: explicit action (otherwise inferred)
scope?: string // optional: scope for this route
}interface Rule {
pattern: string | RegExp // URL path pattern
resource: string // resource type
action?: string // optional: explicit action (otherwise inferred)
scope?: string // optional: scope for this route
}Custom Error Responses
All integrations support custom error handlers:
// Express
accessMiddleware(engine, {
onDenied: (req, res) => {
res.status(403).json({
error: 'forbidden',
message: `You cannot ${req.method.toLowerCase()} this resource`,
})
},
onError: (err, req, res, next) => {
console.error('Auth error:', err)
res.status(500).json({ error: 'internal' })
},
})
// Hono
accessMiddleware(engine, {
onDenied: (c) => c.json({ error: 'forbidden' }, 403),
onError: (err, c) => c.json({ error: 'internal' }, 500),
})
// NestJS (via guard options)
nestAccessGuard(engine, {
onError: (err, req) => {
console.error('Auth error:', err)
return false // deny on error
},
})
// Next.js
withAccess(engine, 'delete', 'post', handler, {
onError: (err, req) => Response.json({ error: 'internal' }, { status: 500 }),
})// Express
accessMiddleware(engine, {
onDenied: (req, res) => {
res.status(403).json({
error: 'forbidden',
message: `You cannot ${req.method.toLowerCase()} this resource`,
})
},
onError: (err, req, res, next) => {
console.error('Auth error:', err)
res.status(500).json({ error: 'internal' })
},
})
// Hono
accessMiddleware(engine, {
onDenied: (c) => c.json({ error: 'forbidden' }, 403),
onError: (err, c) => c.json({ error: 'internal' }, 500),
})
// NestJS (via guard options)
nestAccessGuard(engine, {
onError: (err, req) => {
console.error('Auth error:', err)
return false // deny on error
},
})
// Next.js
withAccess(engine, 'delete', 'post', handler, {
onError: (err, req) => Response.json({ error: 'internal' }, { status: 500 }),
})Default error responses:
401 Unauthorized: no user ID found403 Forbidden: permission denied500 Internal Server Error: unexpected error
Permissions Endpoint Pattern
A common pattern is a dedicated endpoint that returns permissions for the client:
app.get('/api/permissions', async (req, res) => {
const userId = req.headers['x-user-id'] as string
if (!userId) return res.status(401).json({ error: 'unauthorized' })
const scope = req.headers['x-organization'] as string
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post', scope },
{ action: 'update', resource: 'post', scope },
{ action: 'delete', resource: 'post', scope },
{ action: 'manage', resource: 'dashboard', scope },
{ action: 'manage', resource: 'user', scope },
])
res.json(permissions)
})app.get('/api/permissions', async (req, res) => {
const userId = req.headers['x-user-id'] as string
if (!userId) return res.status(401).json({ error: 'unauthorized' })
const scope = req.headers['x-organization'] as string
const permissions = await generatePermissionMap(engine, userId, [
{ action: 'create', resource: 'post', scope },
{ action: 'update', resource: 'post', scope },
{ action: 'delete', resource: 'post', scope },
{ action: 'manage', resource: 'dashboard', scope },
{ action: 'manage', resource: 'user', scope },
])
res.json(permissions)
})The client calls this endpoint and passes the result to AccessProvider (Chapter 7).