Skip to main content

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.


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/posts matches /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

HelperWhere it runsGranularity
createNextMiddlewareEdge (before handler)Route-shape gating
withAccessServer (route handler)Route-shape + resource ID
checkAccessServer (RSC, Server Action)Single check, full control
getPermissionsServer (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

OptionTypeDefaultDescription
getUserId(req) -> string or null or Promiserequired (SEC-101)Extract the subject ID; constructor throws when omitted
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
scopestringundefinedFixed scope for this handler
onError(err, req) -> Response500 JSONCustom error handler

createNextMiddleware options

OptionTypeDefaultDescription
rulesArray<Rule>--Route rules. Required.
getUserId(req) -> string or null or Promise--Extract the subject ID. Required.
onError(err, req) -> Response500 JSONCustom error handler

Rule format

FieldTypeRequiredDescription
patternstring or RegExpyesString prefix match or regex pattern
resourcestringyesResource type for matched routes
actionstringnoFixed action (defaults to HTTP method map)
scopestringnoRequired 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 for withAccess (getUserId, getEnvironment, scope, onError).
  • IamNext.IMiddlewareOptions - options for createNextMiddleware (rules, getUserId, onError).
  • IamNext.IAdminAuthorize - signature of the required authorize callback for createAdminHandlers.
  • IamNext.IAdminOptions - options for createAdminHandlers.
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.