Skip to main content

Next.js app router

Route handler wrapper, server component checks, permission maps, edge middleware, and admin handlers for the Next.js app router

@gentleduck/iam/server/next covers four places a Next.js app router application can ask for a decision: middleware.ts at the edge, a route handler, a server component or server action, and a layout that hydrates the client. Everything is built on the fetch-API Request, so nothing here imports next. The shared extraction and audit behaviour lives in generic helpers; this page documents only what Next.js adds.

Install


npm i @gentleduck/iam
import {
  withIamAccess,
  checkIamAccess,
  getIamPermissions,
  createIamNextMiddleware,
  createIamAdminHandlers,
} from '@gentleduck/iam/server/next'
import type { IamNext } from '@gentleduck/iam/server/next'

All five runtime exports carry the iam prefix since 5.0.0. withAccess, checkAccess, getPermissions, createNextMiddleware, and createAdminHandlers no longer resolve.

Pick a layer

HelperWhere it runsGranularitySees params.idSees an environment
createIamNextMiddlewaremiddleware.ts, before routingpath prefix or regexnoyes
withIamAccessroute handlerroute plus resource idyesyes
checkIamAccessserver component, server actionone explicit checkyou pass itno
getIamPermissionslayout, server componentbatch for client hydrationper checkno

All four call engine.can() or engine.permissions(), so all four resolve the subject through the adapter. None of them is free: the middleware is the earliest gate, not a cheaper one.

Setup

Build the engine in a module the runtime can share

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

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

Write one getUserId and reuse it

It is required by two of the five exports and it is the only place identity is decided.

// lib/subject.ts
import { auth } from '@/lib/auth'

export async function getUserId(_req: Request): Promise<string | null> {
  const session = await auth()
  return session?.user?.id ?? null
}

Gate the routes that need it

// app/api/posts/[id]/route.ts
export const DELETE = withIamAccess(engine, 'delete', 'post', handler, { getUserId })

Hydrate the client from a layout

const permissions = userId ? await getIamPermissions(engine, userId, checks) : {}

One guarded request

This sequence diagram traces DELETE /api/posts/42 through both server-side layers: middleware.ts first, then the wrapped route handler. Each layer runs its own getUserId and its own engine.can().

Loading diagram...

The two layers are independent: the middleware never passes a resource id, and the wrapper never sees which rule matched. A request that only the middleware protects is gated by route shape alone; a request that only withIamAccess protects still reaches the Next.js router. Running both is the normal arrangement — a cheap shape check at the edge, a precise check with params.id in the handler.

withIamAccess

Wraps one app-router route handler.

export function withIamAccess<
  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,
  handler: (req: Request, ctx: RouteContext) => Promise<Response>,
  opts?: IamNext.IWithAccessOptions<TScope>,
): (req: Request, ctx: RouteContext) => Promise<Response>

RouteContext is { params: Promise<Record<string, string>> | Record<string, string> }, so both the Next.js 15 promise form and the older object form are accepted; the wrapper awaits whichever it gets and reads params.id as the resource id.

OptionTypeDefaultMeaning
getUserId(req) => string | null | Promise<string | null>required the factory throws without itSubject id. May be async, unlike Express, Hono, and NestJS.
getEnvironment(req) => IamRequest.IEnvironmentiamExtractEnvironment({ headers: req.headers, method: req.method, url: req.url }){ ip, userAgent, timestamp }.
scopeTScopeundefinedFixed scope for this handler. There is no getScope.
onError(err, req) => ResponseResponse.json({ error: 'Internal server error' }, { status: 500 })Runs when ctx.params, getEnvironment, or engine.can() throws.

The 401 ({ error: 'Unauthorized' }) and 403 ({ error: 'Forbidden' }) responses are fixed: this wrapper has neither onUnauthorized nor onDenied. resource.attributes is always {}.

// app/api/posts/[id]/route.ts
import { withIamAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { getUserId } from '@/lib/subject'

async function handler(_req: Request, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  await deletePost(id)
  return Response.json({ deleted: id })
}

export const DELETE = withIamAccess(engine, 'delete', 'post', handler, {
  getUserId,
  onError: (err) => {
    reportError(err)
    return Response.json({ error: 'Internal server error' }, { status: 500 })
  },
})

Handler errors are not yours

The wrapper returns handler(req, ctx) without awaiting it, so a rejection from your handler is not routed through onError — it propagates to Next.js and becomes the framework's error response. onError covers only the wrapper's own work: awaiting ctx.params, calling getEnvironment, and calling engine.can().

checkIamAccess

A positional wrapper over engine.can() for server components and server actions.

export async function checkIamAccess<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  subjectId: string,
  action: TAction,
  resourceType: TResource,
  resourceId?: string,
  scope?: TScope,
): Promise<boolean>

It builds { type: resourceType, id: resourceId, attributes: {} } and passes undefined for the environment. Any rule keyed on environment.userAgent, environment.ip or a custom key therefore reads as a non-match here — only environment.now, which the engine injects when the environment is absent, is available. Call engine.can() directly with an environment you built when a rule needs one, or when it needs real resource attributes.

// app/posts/[id]/page.tsx
import { checkIamAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const userId = (await auth())?.user?.id ?? ''
  const [canEdit, canDelete] = await Promise.all([
    checkIamAccess(engine, userId, 'update', 'post', id),
    checkIamAccess(engine, userId, 'delete', 'post', id),
  ])

  return (
    <article>
      {canEdit ? <EditButton id={id} /> : null}
      {canDelete ? <DeleteButton id={id} /> : null}
    </article>
  )
}

An empty userId returns false rather than throwing, because engine.can() rejects a subject id that is not a string, is empty, or exceeds 1024 characters.

getIamPermissions

Builds a permission map for client hydration. It is engine.permissions(subjectId, checks) with the argument order kept short; note it takes no environment argument, unlike generateIamPermissionMap in the generic module.

export async function getIamPermissions<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  subjectId: string,
  checks: readonly IamClient.IPermissionCheck<TAction, TResource, TScope>[],
): Promise<IamClient.PermissionMap<TAction, TResource, TScope>>
// app/layout.tsx
import { getIamPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
// export const { AccessProvider } = createIamAccessControl(React) — see client/react
import { AccessProvider } from '@/lib/access-client'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const userId = (await auth())?.user?.id
  const permissions = userId
    ? await getIamPermissions(engine, userId, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team', scope: 'org-acme' },
      ])
    : {}

  return (
    <html lang="en">
      <body>
        <AccessProvider permissions={permissions}>{children}</AccessProvider>
      </body>
    </html>
  )
}

The result holds only the keys you asked for, so consume it as IamClient.PartialPermissionMap on the client. engine.permissions() throws when subjectId is empty or longer than 1024 characters or when the batch exceeds 1024 checks. Key format, escaping, and staleness are on the PermissionMap reference; the consuming side is client/react.

createIamNextMiddleware

Builds an async (req) => Response | null for middleware.ts. null means "not my problem, carry on".

export function createIamNextMiddleware<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  opts: IamNext.IMiddlewareOptions<TAction, TResource, TScope>,
): (req: Request) => Promise<Response | null>
OptionTypeRequiredMeaning
rulesArray<{ pattern, resource, action?, scope? }>yesChecked in order; the first match wins and the rest are ignored.
getUserId(req) => string | null | Promise<string | null>yesNo default. There is no header fallback.
onError(err, req) => ResponsenoDefault 500 { error: 'Internal server error' }.

Rule fields:

FieldTypeRequiredMeaning
patternstring | RegExpyesA string is a pathname.startsWith(pattern) prefix test; a RegExp is pattern.test(pathname).
resourceTResourceyesResource type for the check. No resource id is ever passed.
actionTActionnoFalls back to iamActionForMethod(req.method); an unmapped method yields the reserved refusal token, not 'read'.
scopeTScopenoPassed straight to engine.can().
// middleware.ts
import { NextResponse } from 'next/server'
import { createIamNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { getUserId } from '@/lib/subject'

const check = createIamNextMiddleware(engine, {
  getUserId,
  rules: [
    { pattern: '/api/admin', resource: 'admin', action: 'manage' },
    { pattern: /^\/api\/billing/, resource: 'billing', scope: 'org-acme' },
    { pattern: '/api/posts', resource: 'post' }, // action from the HTTP method
  ],
})

export async function middleware(req: Request) {
  return (await check(req)) ?? NextResponse.next()
}

export const config = { matcher: ['/api/:path*'] }

Order matters, because rules.find() stops at the first hit. A broad '/api' rule placed first shadows every narrower rule after it.

A string pattern is a prefix test (path.startsWith), not a substring test. Under substring matching /admin/public-report would match a /public rule listed first and be authorized as public. The consequence of prefix matching is that a path merely containing a pattern mid-string — /notes/admin-draft against /admin — matches nothing, and no rule means return null: the request passes through with no authorization call at all. A rule list is opt-in.

Path refusals, before rule matching

Two checks run before rules.find(), and both answer onDenied:

  1. iamPathIsAmbiguous(url.pathname) — refused before canonicalisation. /admin/..%2fpublic keeps its %2f through new URL(), so a canonicalising middleware resolved the .., matched a /public rule and allowed, while Next's own router decoded the escape into a path segment and served /admin.
  2. After iamNormalizePathname, path.includes('%') — the double-encoding residue check. Without it, /posts/%252e%252e/admin was checked as posts while routing to /admin, or matched no rule at all and passed through unauthorized.

Normalisation itself matters for a third reason: //admin and /%61dmin both survive new URL() and would skip a /admin prefix rule while still routing to /admin.

createIamNextMiddleware does pass an environment. Its getEnvironment defaults to iamExtractEnvironment({ headers, method, url }), the same as withIamAccess. This was once the one integration that passed nothing at all, so a rule keyed on environment.userAgent was inert exactly where a Next app puts its edge checks.

Error mapping

SituationLayerResponse
the path is ambiguous or holds encoding residuemiddlewareonDenied, default 403 { error: 'Forbidden' }
no rule matchesmiddlewarenull; the request continues, unauthorized
getUserId returns a non-string, '', or a blank stringmiddleware, wrapper401. Fixed in the wrapper; onUnauthorized in the middleware
engine.can() returns falsemiddleware, wrapper403. Fixed in the wrapper; onDenied in the middleware
any extractor throws, getUserId includedmiddleware, wrapperonError, default 500
engine.can() rejectsmiddleware, wrapperonError, default 500
the wrapped handler throwswrappernot caught; Next.js reports it

A blank subject id is refused by iamIsSubjectId at this layer, 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.

getUserId runs inside the try in both layers, so a throwing or rejecting one reaches onError rather than escaping to Next's own boundary, where it would be reported as an application error rather than an authorization one.

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. See engine methods.

Forwarded-IP normalisation

withIamAccess and createIamNextMiddleware both default getEnvironment to iamExtractEnvironment({ headers, method, url }), which leaves environment.ip undefined. The forwarded headers are handed over, but the extractor declines to read them without trustProxy: a fetch Request has no socket peer to compare against, so with nothing in front of the app they are headers the client sets itself. Measured against real servers, Next echoed a plain X-Forwarded-For: 10.0.0.1 into environment.ip before this changed, and a header alone satisfied an IP-conditioned admin grant.

A condition on environment.ip on a default wiring reads as a non-match, not an error: a deny rule keyed on it never fires, and the request is allowed, silently.

Opt in only when something in front of the app overwrites those headers on every request:

getEnvironment: (req) =>
  iamExtractEnvironment({ headers: req.headers, method: req.method, url: req.url }, { trustProxy: true })

Under trustProxy the chain is the leftmost x-forwarded-for hop, then x-real-ip, each normalised: reject the whole header above 4096 characters, take the text before the first comma, trim, reject if blank or above 256. The full decision path is on the generic helpers page.

export const GET = withIamAccess(engine, 'read', 'report', handler, {
  getUserId,
  getEnvironment: (req) => ({
    ip: req.headers.get('x-vercel-forwarded-for') ?? undefined,
    userAgent: req.headers.get('user-agent') ?? undefined,
    timestamp: Date.now(),
  }),
})

Admin route handlers

createIamAdminHandlers returns six pre-bound app-router handlers.

export function createIamAdminHandlers<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(
  engine: IamEngine<TAction, TResource, TRole, TScope>,
  opts: IamNext.IAdminOptions,
): {
  listPolicies: RouteHandler
  listRoles: RouteHandler
  savePolicy: RouteHandler
  saveRole: RouteHandler
  assignRole: RouteHandler
  revokeRole: RouteHandler
}
// app/api/admin/handlers.ts
import { createIamAdminHandlers } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'

export const h = createIamAdminHandlers(engine, {
  authorize: async (req) => (await getSession(req))?.user?.role === 'platform-admin',
  onAdminMutation: (event) => auditLog.write(event),
  redactPath: (p) => p.replace(/\/[^/]+$/, '/:id'),
})
// app/api/admin/policies/route.ts
export const GET = h.listPolicies
export const PUT = h.savePolicy

// app/api/admin/subjects/[id]/roles/route.ts
export const POST = h.assignRole

// app/api/admin/subjects/[id]/roles/[roleId]/route.ts
export const DELETE = h.revokeRole
HandlerEngine callAudit action / targettargetId
listPoliciesadmin.listPolicies()none (reads never audit)
listRolesadmin.listRoles()none
savePolicyadmin.savePolicy(await req.json())replace / policyundefined
saveRoleadmin.saveRole(await req.json())replace / roleundefined
assignRoleadmin.assignRole(params.id, body.roleId, body.scope)create / role-assignmentparams.id
revokeRoleadmin.revokeRole(params.id, params.roleId)delete / role-assignmentparams.id

event.path is new URL(req.url).pathname, falling back to the raw req.url when the URL cannot be parsed. Route params are resolved before the audit wrapper runs, so a rejecting ctx.params produces the onError response and no audit event.

Options

OptionTypeDefaultMeaning
authorize(req) => unknown | Promise<unknown>requiredRuns on every handler, read and write. A falsy return (false, 0, '', null, undefined, NaN) is 401; a throw is 500. Return the actor itself, not true true authorizes and records actor: undefined with a one-time console.warn.
csrfCheck((req) => boolean) | falseiamDefaultCsrfCheckRuns on every admin request, reads included. false disables the phase.
onUnauthorized(req) => Response401 { error: 'Unauthorized' }Replaces the falsy-authorize response.
onError(err, req) => Response500 { error: 'Internal server error' }Wraps a throwing authorize, param resolution, or handler.
onAdminMutationIamAdminAudit.HooknoneFire-and-forget, fires on success and failure, never on a read.
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.

Rate limiting is out of scope; put it in middleware.ts in front of /api/admin/.

API reference

ExportKindPurpose
withIamAccess(engine, action, resourceType, handler, opts?)functionRoute-handler wrapper; getUserId required
checkIamAccess(engine, subjectId, action, resourceType, resourceId?, scope?)functionBoolean check for server components and actions
getIamPermissions(engine, subjectId, checks)functionPermission map for client hydration
createIamNextMiddleware(engine, opts)function(req) => Response | null for middleware.ts
createIamAdminHandlers(engine, opts)functionSix pre-bound admin route handlers
IamNext.IWithAccessOptions<TScope>interfacegetUserId, getEnvironment, scope, onError
IamNext.IMiddlewareOptions<TAction, TResource, TScope>interfacerules, getUserId, getEnvironment, onDenied, onUnauthorized, onError
IamNext.IAdminAuthorizetype(req) => unknown | Promise<unknown>
IamNext.IAdminOptionsinterfaceauthorize plus onUnauthorized, onError, and IamAdminAudit.IOptions
import type { IamNext } from '@gentleduck/iam/server/next'

const opts: IamNext.IWithAccessOptions = {
  getUserId: async () => (await auth())?.user?.id ?? null,
}

const adminAuth: IamNext.IAdminAuthorize = async (req) =>
  (await getSession(req))?.user?.role === 'platform-admin'

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

Gotchas

  • withIamAccess binds params.id and nothing else. For [postId], keep the wrapper as a coarse gate and call engine.can() inside the handler with the real identifier.
  • The wrapper's 401 and 403 bodies are fixed. If your API needs a different shape, wrap the wrapper or check with checkIamAccess inside the handler.
  • The middleware ignores every rule after the first match, and ignores resource ids entirely.
  • checkIamAccess passes no environment, so a policy reading $environment.ip or $environment.userAgent reads as a non-match there. createIamNextMiddleware does pass one, with ip left undefined like everywhere else.
  • getIamPermissions returns only the keys you asked for despite the PermissionMap return type; type the client side as IamClient.PartialPermissionMap.
  • new Request(url) resolves dot segments while constructing the URL, so a traversal written on the wire never reaches Next middleware as a traversal — it is handed /public and authorizes public, which is self-consistent and safe, while Express, Nest and the generic helper still see the raw target and refuse it. A recorded, deliberate divergence.
  • Nothing in this module ever answers 404. Every refusal it produces is 400, 401, 403 or 500.
  • Route handlers wrapped for the Edge runtime need an edge-compatible adapter. The wrapper itself has no Node dependency.

See also