next.js app router
Route handler wrapper, server component checks, permission map generation, and edge middleware for Next.js.
Install
import {
withAccess,
checkAccess,
getPermissions,
createNextMiddleware,
} from '@gentleduck/iam/server/next'
Covers four protection layers in Next.js App Router: route handler wrappers, Server Component / Server Action checks, permission map generation for client hydration, and edge middleware.
Route handler wrapper
Wrap App Router route handlers (GET, POST, DELETE, etc.).
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'
async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
await deletePost(id)
return Response.json({ deleted: true })
}
export const DELETE = withAccess(engine, 'delete', 'post', handler, {
// SEC-101: `getUserId` is REQUIRED. The constructor throws when omitted.
// Never derive identity from request headers - it's spoofable.
getUserId: async (req) => {
const session = await auth()
return session?.user?.id ?? null
},
})
The wrapper automatically reads params.id as the resource instance ID. params may be a Promise (Next.js 15+) or an object.
withAccess throws at construction when opts.getUserId is omitted. The
previous default trusted the x-user-id request header, which any
unauthenticated client can spoof with curl -H 'X-User-Id: admin'. Wire it
from your auth library (NextAuth auth(), Clerk auth(), cookie session, JWT).withAccess() only auto-binds params.id. If your route uses params.postId or another
name, keep the wrapper for coarse route gating and run a deeper engine.can() /
engine.check() inside the handler once you have the real instance identifier.Server component checks
Check a permission inside a React Server Component or Server Action:
import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
const canEdit = await checkAccess(engine, userId, 'update', 'post', id)
return (
<article>
<h1>Post {id}</h1>
{canEdit && <EditButton />}
{canDelete && <DeleteButton />}
</article>
)
}
checkAccess is a thin wrapper around engine.can() with positional args matching common Next.js patterns (subjectId, action, resourceType, resourceId?, scope?).
Permission map generation
Generate a PermissionMap on the server and pass it to the client for hydration:
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
const userId = session?.user?.id
const permissions = userId
? await getPermissions(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'team' },
{ action: 'read', resource: 'analytics' },
])
: {}
return (
<html>
<body>
<AccessProvider permissions={permissions}>{children}</AccessProvider>
</body>
</html>
)
}
See client/react for the consuming side.
Edge middleware
Protect routes at the edge before they reach application code:
// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'
const checkAccess = createNextMiddleware(engine, {
// SEC-101: derive identity from a verified source - never trust a raw
// header. Cookie session, JWT, or your auth library's session helper.
getUserId: async (req) => {
const session = await getServerSession(req)
return session?.user?.id ?? null
},
rules: [
{ pattern: '/api/admin', resource: 'admin', action: 'manage' },
{ pattern: '/api/posts', resource: 'post' }, // action inferred from HTTP method
{ pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
],
})
export async function middleware(req: Request) {
const denied = await checkAccess(req)
if (denied) return denied // 401 or 403 Response
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}
When no rule matches a request path, the middleware returns null and the request passes through.
Rule patterns
- String pattern - simple prefix match (
/api/postsmatches/api/posts,/api/posts/42,/api/posts/foo/bar) - RegExp pattern - full regex test against
pathname(use for stricter matching, optional segments, glob-like behavior)
Comparing the four entry points
| Helper | Where it runs | Granularity |
|---|---|---|
createNextMiddleware | Edge (before handler) | Route-shape gating |
withAccess | Server (route handler) | Route-shape + resource ID |
checkAccess | Server (RSC, Server Action) | Single check, full control |
getPermissions | Server (Layout, RSC) | Batch generation for hydration |
createNextMiddleware() is the cheapest - it runs at the edge and never touches the database adapter unless you wire one in. withAccess() runs in the function context with full subject resolution. checkAccess() and getPermissions() are explicit calls inside server logic.
Options reference
withAccess options
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (req) -> string or null or Promise | required (SEC-101) | Extract the subject ID; constructor throws when omitted |
getEnvironment | (req) -> Environment | IP + user agent + timestamp | Extract environment context |
scope | string | undefined | Fixed scope for this handler |
onError | (err, req) -> Response | 500 JSON | Custom error handler |
createNextMiddleware options
| Option | Type | Default | Description |
|---|---|---|---|
rules | Array<Rule> | -- | Route rules. Required. |
getUserId | (req) -> string or null or Promise | -- | Extract the subject ID. Required. |
onError | (err, req) -> Response | 500 JSON | Custom error handler |
Rule format
| Field | Type | Required | Description |
|---|---|---|---|
pattern | string or RegExp | yes | String prefix match or regex pattern |
resource | string | yes | Resource type for matched routes |
action | string | no | Fixed action (defaults to HTTP method map) |
scope | string | no | Required scope for matched routes |
Admin route handlers
createAdminHandlers returns pre-bound Route Handlers for admin CRUD. The authorize callback is required - the factory throws if it's missing.
// app/api/admin/policies/route.ts
import { createAdminHandlers } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
const h = createAdminHandlers(engine, {
authorize: async (req) => {
const session = await getSession(req)
return session?.user?.role === 'platform-admin'
},
})
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
Every handler runs authorize(req) first; falsy -> 401. Errors route through optional onError(err, req).
Available handlers: listPolicies, listRoles, savePolicy, saveRole, assignRole, revokeRole.
CSRF protection (default-on)
Mutation handlers (savePolicy, saveRole, assignRole, revokeRole) run
a Sec-Fetch-Site check by default (SEC-103 / CAVEAT-2). Browsers populate
the header automatically; cross-site form posts are rejected with 403,
same-site / same-origin requests pass. Non-browser callers (no header) pass -
they must be gated by bearer / mTLS.
// Default - cookie-auth admin UIs get CSRF protection with no opt-in.
createAdminHandlers(engine, { authorize })
// Server-to-server bearer/mTLS API - disable the check entirely.
createAdminHandlers(engine, { authorize, csrfCheck: false })
// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
createAdminHandlers(engine, {
authorize,
csrfCheck: (req) => ADMIN_ORIGINS.has(req.headers.get('origin') ?? ''),
})
withAccess options conform to IamNext.IWithAccessOptions, createNextMiddleware options conform to IamNext.IMiddlewareOptions, and createAdminHandlers options conform to IamNext.IAdminOptions with authorize of shape IamNext.IAdminAuthorize.
Types
All types live under the Next namespace at @gentleduck/iam/server/next. Type-only - zero bundle cost.
IamNext.IWithAccessOptions- options forwithAccess(getUserId,getEnvironment,scope,onError).IamNext.IMiddlewareOptions- options forcreateNextMiddleware(rules,getUserId,onError).IamNext.IAdminAuthorize- signature of the requiredauthorizecallback forcreateAdminHandlers.IamNext.IAdminOptions- options forcreateAdminHandlers.
import type { IamNext } from '@gentleduck/iam/server/next'
const opts: IamNext.IWithAccessOptions = {
getUserId: async (req) => (await auth())?.user?.id ?? null,
}
const adminAuth: IamNext.IAdminAuthorize = async (req) => {
const session = await getSession(req)
return session?.user?.role === 'platform-admin'
}
Deprecated bare aliases (IWithAccessOptions, IMiddlewareOptions, IAdminAuthorize, IAdminOptions) remain for back-compat and will be removed in 3.0.