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
| Helper | Where it runs | Granularity | Sees params.id | Sees an environment |
|---|---|---|---|---|
createIamNextMiddleware | middleware.ts, before routing | path prefix or regex | no | yes |
withIamAccess | route handler | route plus resource id | yes | yes |
checkIamAccess | server component, server action | one explicit check | you pass it | no |
getIamPermissions | layout, server component | batch for client hydration | per check | no |
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().
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.
| Option | Type | Default | Meaning |
|---|---|---|---|
getUserId | (req) => string | null | Promise<string | null> | required — the factory throws without it | Subject id. May be async, unlike Express, Hono, and NestJS. |
getEnvironment | (req) => IamRequest.IEnvironment | iamExtractEnvironment({ headers: req.headers, method: req.method, url: req.url }) | { ip, userAgent, timestamp }. |
scope | TScope | undefined | Fixed scope for this handler. There is no getScope. |
onError | (err, req) => Response | Response.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 })
},
})
withIamAccess throws at construction when opts.getUserId is omitted: [@gentleduck/iam:next] opts.getUserId is required - deriving identity from request headers is unsafe. Wire it from your auth middleware (cookie session, JWT, etc.). Before 2.1.0 the default trusted the x-user-id request header, which any unauthenticated client can forge with curl -H 'X-User-Id: admin'. The test throws at construction when getUserId is omitted pins the current behaviour. Derive the subject from NextAuth auth(), Clerk, a verified JWT, or your own cookie session.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>
| Option | Type | Required | Meaning |
|---|---|---|---|
rules | Array<{ pattern, resource, action?, scope? }> | yes | Checked in order; the first match wins and the rest are ignored. |
getUserId | (req) => string | null | Promise<string | null> | yes | No default. There is no header fallback. |
onError | (err, req) => Response | no | Default 500 { error: 'Internal server error' }. |
Rule fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
pattern | string | RegExp | yes | A string is a pathname.startsWith(pattern) prefix test; a RegExp is pattern.test(pathname). |
resource | TResource | yes | Resource type for the check. No resource id is ever passed. |
action | TAction | no | Falls back to iamActionForMethod(req.method); an unmapped method yields the reserved refusal token, not 'read'. |
scope | TScope | no | Passed 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:
iamPathIsAmbiguous(url.pathname)— refused before canonicalisation./admin/..%2fpublickeeps its%2fthroughnew URL(), so a canonicalising middleware resolved the.., matched a/publicrule and allowed, while Next's own router decoded the escape into a path segment and served/admin.- After
iamNormalizePathname,path.includes('%')— the double-encoding residue check. Without it,/posts/%252e%252e/adminwas checked aspostswhile 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.
createIamNextMiddleware calls engine.can(), which resolves the subject through your adapter exactly like the route-handler path. On the Edge runtime that means your adapter must be edge-compatible (memory, HTTP, or an edge-capable driver). It is the earliest gate available, not a cheaper one.Error mapping
| Situation | Layer | Response |
|---|---|---|
| the path is ambiguous or holds encoding residue | middleware | onDenied, default 403 { error: 'Forbidden' } |
| no rule matches | middleware | null; the request continues, unauthorized |
getUserId returns a non-string, '', or a blank string | middleware, wrapper | 401. Fixed in the wrapper; onUnauthorized in the middleware |
engine.can() returns false | middleware, wrapper | 403. Fixed in the wrapper; onDenied in the middleware |
any extractor throws, getUserId included | middleware, wrapper | onError, default 500 |
engine.can() rejects | middleware, wrapper | onError, default 500 |
| the wrapped handler throws | wrapper | not 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.
x-forwarded-for and also exposes x-vercel-forwarded-for. Behind your own chain of proxies, count from the right: the leftmost entry is whatever the outermost proxy was handed, including a value the client invented. Pass a custom getEnvironment in either case.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
| Handler | Engine call | Audit action / target | targetId |
|---|---|---|---|
listPolicies | admin.listPolicies() | none (reads never audit) | — |
listRoles | admin.listRoles() | none | — |
savePolicy | admin.savePolicy(await req.json()) | replace / policy | undefined |
saveRole | admin.saveRole(await req.json()) | replace / role | undefined |
assignRole | admin.assignRole(params.id, body.roleId, body.scope) | create / role-assignment | params.id |
revokeRole | admin.revokeRole(params.id, params.roleId) | delete / role-assignment | params.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
| Option | Type | Default | Meaning |
|---|---|---|---|
authorize | (req) => unknown | Promise<unknown> | required | Runs 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) | false | iamDefaultCsrfCheck | Runs on every admin request, reads included. false disables the phase. |
onUnauthorized | (req) => Response | 401 { error: 'Unauthorized' } | Replaces the falsy-authorize response. |
onError | (err, req) => Response | 500 { error: 'Internal server error' } | Wraps a throwing authorize, param resolution, or handler. |
onAdminMutation | IamAdminAudit.Hook | none | Fire-and-forget, fires on success and failure, never on a read. |
redactPath | (path: string) => string | identity | Rewrites event.path before the hook. |
onAuditHookError | (err, event) => void | console.error | Sink for a throwing hook. |
includeErrorMessage | boolean | false | event.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.
createIamAdminHandlers throws at construction when opts.authorize is not a function: [@gentleduck/iam] createIamAdminHandlers requires an authorize callback. These handlers write policies, roles, and assignments straight to the adapter, so there is no useful unauthenticated mode.iamDefaultCsrfCheck before authorize, rejecting browser requests whose Sec-Fetch-Site is cross-site or cross-origin with 403 { error: 'Forbidden (CSRF check failed)' } and firing no audit event. That response is fixed and not covered by onUnauthorized or onError. A predicate that throws is also a 403. Requests with no such header (curl, server-to-server) pass and must be gated by bearer or mTLS auth inside authorize. A one-time console.info names the change when you did not pass csrfCheck explicitly.assignRole accepts scope in the body; revokeRole does not. It calls admin.revokeRole(subjectId, roleId) with no third argument, and the adapter contract for an omitted scope is remove every assignment for that role, across every scope. One DELETE against a subject holding that role in five tenants removes all five. To drop a single scoped assignment, write your own route calling engine.admin.revokeRole(subjectId, roleId, scope). See scoped roles.iamReadJsonBody answers 400 { error: 'Invalid request', issues: ['MALFORMED_JSON'] } for a truncated upload or a form post carrying Content-Type: application/json. Before that, the SyntaxError escaped into the generic catch and became a 500 — telling a client to retry a request that can never succeed. The parser's own message is deliberately not repeated: it quotes the offending bytes, which is caller-controlled content on its way into an operator's log.Rate limiting is out of scope; put it in middleware.ts in front of /api/admin/.
API reference
| Export | Kind | Purpose |
|---|---|---|
withIamAccess(engine, action, resourceType, handler, opts?) | function | Route-handler wrapper; getUserId required |
checkIamAccess(engine, subjectId, action, resourceType, resourceId?, scope?) | function | Boolean check for server components and actions |
getIamPermissions(engine, subjectId, checks) | function | Permission map for client hydration |
createIamNextMiddleware(engine, opts) | function | (req) => Response | null for middleware.ts |
createIamAdminHandlers(engine, opts) | function | Six pre-bound admin route handlers |
IamNext.IWithAccessOptions<TScope> | interface | getUserId, getEnvironment, scope, onError |
IamNext.IMiddlewareOptions<TAction, TResource, TScope> | interface | rules, getUserId, getEnvironment, onDenied, onUnauthorized, onError |
IamNext.IAdminAuthorize | type | (req) => unknown | Promise<unknown> |
IamNext.IAdminOptions | interface | authorize 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
withIamAccessbindsparams.idand nothing else. For[postId], keep the wrapper as a coarse gate and callengine.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
checkIamAccessinside the handler. - The middleware ignores every rule after the first match, and ignores resource ids entirely.
checkIamAccesspasses no environment, so a policy reading$environment.ipor$environment.userAgentreads as a non-match there.createIamNextMiddlewaredoes pass one, withipleft undefined like everywhere else.getIamPermissionsreturns only the keys you asked for despite thePermissionMapreturn type; type the client side asIamClient.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/publicand authorizespublic, 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
- Server integrations overview for the shared pipeline and the cross-framework comparison
- Generic helpers for
iamExtractEnvironment, the CSRF predicate, and the audit pipeline - PermissionMap reference and client/react for the hydration side
- Hono for the other edge-capable wrapper
- Admin API for what the admin handlers call
- Engine methods for
can,check, andpermissions