Skip to main content
Search...

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 you need to protect actual HTTP endpoints. This chapter shows how to wire duck-iam into four popular frameworks -- pick the one you use.

Loading diagram...

How It Works

All server integrations share the same pattern:

  1. Extract the user ID from the request (JWT, session, header)
  2. Determine the action (from HTTP method) and resource (from URL path)
  3. Call engine.can() with the extracted context
  4. 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. You can 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

These framework-agnostic utilities 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)
 
// Now use it without repeating the 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)
 
// Now use it without repeating the 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
}

Express

Global middleware

src/server.ts
import express from 'express'
import { engine } from './access'
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,
}))
src/server.ts
import express from 'express'
import { engine } from './access'
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,
}))

The middleware checks every request against the engine. If the user ID is missing, it returns 401. If the permission is denied, it 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

src/server.ts
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 })
  }
)
src/server.ts
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() is more explicit -- you specify the action and resource. The resource ID is auto-extracted from req.params.id.

Admin router

src/server.ts
import { Router } from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
 
// Mount admin endpoints
app.use('/api/access-admin', adminRouter(engine)(() => Router()))
src/server.ts
import { Router } from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'
 
// Mount admin endpoints
app.use('/api/access-admin', adminRouter(engine)(() => Router()))

Exposed endpoints:

MethodPathDescriptionBody
GET/policiesList all policies
GET/rolesList all roles
PUT/policiesSave/update a policyPolicy object
PUT/rolesSave/update a roleRole object
POST/subjects/:id/rolesAssign role to subject{ roleId, scope? }
DELETE/subjects/:id/roles/:roleIdRevoke role from subject

Protect the admin router with your own auth middleware. Never expose it to unauthenticated users.

Hono

Global middleware

src/server.ts
import { Hono } from 'hono'
import { engine } from './access'
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'),
}))
src/server.ts
import { Hono } from 'hono'
import { engine } from './access'
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 the cf-connecting-ip header (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

src/server.ts
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') })
  }
)
src/server.ts
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

src/access/access.module.ts
import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access'
 
@Module({
  providers: [
    createEngineProvider(() => engine),
  ],
  exports: [ACCESS_ENGINE_TOKEN],
})
export class AccessModule {}
src/access/access.module.ts
import { Module } from '@nestjs/common'
import { createEngineProvider, ACCESS_ENGINE_TOKEN } from '@gentleduck/iam/server/nest'
import { engine } from './access'
 
@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

src/access/access.guard.ts
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)
  }
}
src/access/access.guard.ts
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

src/posts/posts.controller.ts
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 []
  }
}
src/posts/posts.controller.ts
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
  • Resource is inferred from the route path (last non-parameter segment)

If no @Authorize decorator is present on a handler, the guard allows the request through (no authorization check is performed).

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

src/access/authorize.ts
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 error
src/access/authorize.ts
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 error

Next.js (App Router)

Protect API route handlers

app/api/posts/[id]/route.ts
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
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',
  }
)
app/api/posts/[id]/route.ts
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
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
}

Note: getUserId can be async -- useful for reading from cookies or JWT.

Check permissions in Server Components

app/posts/[id]/page.tsx
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>
  )
}
app/posts/[id]/page.tsx
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

middleware.ts
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*'],
}
middleware.ts
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) -- /api/admin matches /api/admin/users
  • Regex patterns use pattern.test(path) -- for precise matching
  • First matching rule is used (order matters)
  • If no rule matches, the request passes through (no authorization check)
  • If no action is specified in the rule, it is 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 found
  • 403 Forbidden -- permission denied
  • 500 Internal Server Error -- unexpected error

Permissions Endpoint Pattern

A common pattern is to create a dedicated endpoint that returns permissions for the client:

Express example
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)
})
Express example
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).


Chapter 6 FAQ

Which framework integration should I use?

Use the integration that matches your backend framework. Express and Hono provide middleware functions. NestJS provides decorators and guards. Next.js provides route handler wrappers and Server Component helpers. If your framework is not listed, use the generic helpers (generatePermissionMap, createSubjectCan, extractEnvironment) and call engine.can() directly in your middleware.

Should I use global middleware or per-route guards?

Per-route guards are more explicit and easier to reason about -- you see exactly what action and resource each route requires. Global middleware is convenient for APIs where the URL structure maps cleanly to resources. Most apps use a combination: global middleware for the common case and per-route guards where you need explicit control.

How do I extract the user ID from a JWT?

duck-iam does not handle authentication -- it only handles authorization. Use your existing auth middleware (passport, next-auth, clerk, etc.) to verify the JWT and attach the user to the request. Then pass getUserId: (req) => req.user.id to the duck-iam middleware. Separation of auth and authz keeps both systems simpler.

How do I extract the scope from the URL?

Use the getScope callback. For a URL like /api/orgs/acme/posts, extract the org slug: getScope: (req) => req.params.orgId (Express) or getScope: (c) => c.req.param('orgId') (Hono). You can also read it from a header (X-Organization) or from JWT claims.

Should I protect the admin router?

Yes. The admin router exposes mutation endpoints for policies, roles, and assignments. Add your own authentication and authorization middleware before mounting it. For example, use a guard(engine, 'manage', 'dashboard') before the admin router to restrict it to admin users only.

Does duck-iam depend on Express/Hono/NestJS?

No. Each server integration uses lightweight type interfaces instead of importing the framework. This means duck-iam does not add the framework to your bundle or require specific versions. The integration code uses generic request/response shapes that are compatible with the framework's types.

How does NestJS action/resource inference work?

When @Authorize() uses infer: true (the default), the guard reads the HTTP method to determine the action (via METHOD_ACTION_MAP) and parses the controller route path for the resource type. It takes the last non-parameter segment (ignoring :id style params). For @Controller('posts') with @Delete(':id'), the inferred action is delete and resource is posts.


Next: Chapter 7: Client Libraries