Next.js Integration¶
@duar-auth/nextjs provides Edge Middleware for JWT validation and server helpers for Server Components and Route Handlers.
AuthZ Middleware¶
Validates dual tokens (IdP + Duar authz) at the edge.
// middleware.ts
import { createDuarAuthzMiddleware } from '@duar-auth/nextjs/authz-middleware'
export default createDuarAuthzMiddleware({
duarUrl: process.env.DUAR_URL!,
idpJwksUrl: 'https://www.googleapis.com/oauth2/v3/certs',
idpAudience: process.env.GOOGLE_CLIENT_ID!,
idpIssuer: 'https://accounts.google.com',
serviceName: 'my-app',
publicPaths: ['/login', '/auth/callback'],
})
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }
Next.js 16: name the file
proxy.ts(middleware.tsstill works there but is deprecated); the default export works unchanged. This is unrelated to@duar-auth/nextjs/proxy, the route-handler reverse proxy. Whatever the file is called, it must exist: without it nothing strips client-sentx-duar-*headers.
| Option | Type | Default | Description |
|---|---|---|---|
duarUrl |
string |
required | Duar URL (derives JWKS endpoint) |
idpJwksUrl |
string |
required | IdP JWKS URL for token verification |
idpAudience |
string \| string[] |
required | Your app's OAuth client_id. Rejects tokens minted for any other client of the same IdP. |
idpIssuer |
string |
undefined |
Expected IdP iss claim. Strongly recommended. |
serviceName |
string |
required | Your service's name (as registered in Duar). Authz token's svc claim must equal this — stops cross-service token replay. |
effectiveScope |
string |
undefined |
Realm slug (this service's shared scope). When set, the authz token's svc may equal either serviceName or this — so a realm member accepts a realm-shared user token (Flow A). Resolve it once at startup with fetchWhoami from @duar-auth/js/server. Omit for standalone apps. |
publicPaths |
string[] |
[] |
Paths that skip auth |
loginPath |
string |
"/login" |
Redirect for unauthenticated page requests |
autoResolve |
boolean |
false |
Mint the authz token server-side when a request carries only the IdP token plus X-Workspace-Id (scripts, Postman, Swagger). Requires serviceKey and idpProvider. |
serviceKey |
string |
undefined |
Service key used for POST /authz/resolve. Server-only env — never NEXT_PUBLIC_. |
idpProvider |
string |
undefined |
Provider Duar validates the IdP token as: 'google' or 'entra_id'. |
What it does: strips spoofed x-duar-* headers, verifies IdP token (signature + aud + optional iss) against IdP JWKS, verifies authz token against Duar JWKS, checks idp_sub binding, checks svc binding, sets x-duar-* headers for downstream components. API routes get 401 JSON; page routes redirect.
Calling the API from scripts (auto-resolve)¶
export default createDuarAuthzMiddleware({
...,
autoResolve: true,
serviceKey: process.env.DUAR_SERVICE_KEY!,
idpProvider: 'google',
})
curl https://app.example.com/api/items \
-H "Authorization: Bearer $ID_TOKEN" \
-H "X-Workspace-Id: 5e60ba90-4b3e-4b1a-9dcb-9d76b1a1e3a1"
The IdP token is verified first, then the middleware mints through POST /authz/resolve, caches per (idp_sub, workspace) for 80% of the token TTL, de-duplicates concurrent first requests, and forwards the minted token as x-authz-token to your route handlers. X-Authz-Token wins when both headers are sent. Responses: missing both headers → 401 (detail names the headers); non-UUID workspace → 400; Duar rejects the IdP token → 401 (Duar's reason appended); not a member → 403; Duar rate limit → 429 with Retry-After; Duar rejects this app's service key, is unreachable, or returns an unusable body → 503. Every auto-resolve response is JSON whether the path is an API or page route — a caller sending a Bearer token plus X-Workspace-Id is an API client by construction; page navigations without a Bearer token still redirect to loginPath.
Proxy Middleware¶
For Duar's redirect-based OAuth flow. Validates a single JWT.
// middleware.ts
import { createDuarMiddleware } from '@duar-auth/nextjs/middleware'
export default createDuarMiddleware({
jwksUrl: process.env.DUAR_JWKS_URL!,
publicPaths: ['/login', '/auth/callback'],
})
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }
Additional options: audience (default "duar:access"), allowedWorkspaces (optional workspace ID allowlist). Reads token from Authorization: Bearer header or duar_access_token cookie.
Headers set by middleware¶
Both variants set these on success, readable in Server Components and Route Handlers:
| Header | Value |
|---|---|
x-duar-user-id |
User ID |
x-duar-email |
Email (percent-encoded) |
x-duar-name |
Display name (percent-encoded) |
x-duar-workspace-id |
Workspace ID |
x-duar-workspace-slug |
Workspace slug |
x-duar-workspace-role |
Workspace role |
x-duar-idp-sub |
IdP subject the authz token is bound to (authz middleware only) |
x-duar-actions |
Comma-separated RBAC actions (authz middleware only) |
x-duar-org-id |
The user's organization ID, from their email domain (omitted when the user has no org) |
x-duar-org-slug |
The user's organization slug (omitted when the user has no org) |
x-duar-org-public |
true if the user is in the public org; false otherwise, including no org |
x-authz-token |
The Duar authz token — on the auto-resolve path the middleware sets it so route handlers see the minted token |
These headers are only trustworthy behind the middleware. It deletes every client-sent
x-duar-*header on every path, then sets verified values. On a route itsmatcherexcludes, or if the middleware is skipped, anything that reads them, including the deprecated baregetUser(),requireUser()andwithAuth(), sees whatever the client sent. The server helpers verify the token instead, so prefer them. Keep thematchercovering every route anyway (it does the redirects and 401s and mints auto-resolve tokens), and keep Next.js on the latest patch of its release line: middleware bypasses recur (CVE-2025-29927, then CVE-2026-44573, -44574, -44575, -45109 and -64642). The peer range^14.2.25 || ^15.5.18 || ^16.2.11excludes the versions with known App Router bypasses, but it is only a floor: npm refuses a conflicting install, while pnpm, yarn and--legacy-peer-depsonly warn, so checknpm ls next. 14.x is end-of-life and still has CVE-2026-44573 (Pages Router withi18n); Pages Router code that readsx-duar-*itself should move to 15.5.18+.Org and action headers.
x-duar-org-*describe the user, not the workspace: one workspace can hold members of several orgs, so key data and authorization onx-duar-workspace-id, never the org, and testx-duar-org-id(notx-duar-org-public: false) for org membership.x-duar-actionsis copied from the authz token, so a revoked role stays listed until the token is re-minted (AUTHZ_TOKEN_EXPIRE_MINUTES, default 5). When a check must see a revocation immediately, callRoleClient.checkAction().Prefer the server helpers over reading these directly. If you do read the raw headers,
decodeURIComponent()x-duar-emailandx-duar-name: they are percent-encoded on the wire (HTTP header values are Latin-1, so a display name like中文orZoëwould otherwise throw).
Server helpers¶
Build them from the same config object you pass the middleware. They verify the request's
token themselves, with the middleware's exact checks, instead of trusting the x-duar-*
headers. On a route the middleware skipped (a matcher gap, a Next.js middleware-bypass bug)
they return the verified user or null, never a forged one.
// duar.config.ts: plain data, so both middleware.ts and server code can import it
import type { DuarMiddlewareConfig } from '@duar-auth/nextjs/middleware'
export const duarConfig: DuarMiddlewareConfig = {
jwksUrl: process.env.DUAR_JWKS_URL!,
publicPaths: ['/login', '/auth/callback'],
}
// lib/auth.ts
import { createDuarServer } from '@duar-auth/nextjs/server'
import { duarConfig } from '@/duar.config'
export const { getUser, requireUser, withAuth } = createDuarServer(duarConfig)
| Factory | Use with | Verifies |
|---|---|---|
createDuarServer(config) |
createDuarMiddleware |
The access token from Authorization: Bearer or the duar_access_token cookie: signature, audience, issuer, allowedWorkspaces |
createDuarAuthzServer(config) |
createDuarAuthzMiddleware |
The IdP token (Authorization) and X-Authz-Token: both signatures, audiences and issuers, plus the idp_sub and svc bindings. It never mints: on the auto-resolve path the middleware forwards the token it minted, so an auto-resolve request the middleware didn't handle gets null. |
Signing keys are cached (refetched every 10 minutes, or sooner when a token names an unknown
key), so a call usually costs a signature check or two, well under a millisecond. The config is
validated on the first call rather than at import, so building the helpers at module scope
doesn't break next build when the env vars only exist at runtime.
getUser() -- returns DuarUser | null.
// app/dashboard/page.tsx (Server Component)
import { getUser } from '@/lib/auth'
export default async function DashboardPage() {
const user = await getUser()
if (!user) return <p>Not authenticated</p>
return <p>Welcome, {user.name}!</p>
}
requireUser() -- returns DuarUser or throws Error('Unauthorized').
withAuth(handler) -- HOC for Route Handlers. Without a verified user it answers 401
{"detail": "Unauthorized"}, the middleware's shape, and never runs the handler.
// app/api/notes/route.ts
import { withAuth } from '@/lib/auth'
export const GET = withAuth(async (req, user) => {
return Response.json({ workspace: user.workspaceId })
})
getToken() -- raw JWT string from the Authorization header, imported from @duar-auth/nextjs/server.
Deprecated: the bare
getUser(),requireUser()andwithAuth()exported from@duar-auth/nextjs/serverread thex-duar-*headers without verifying anything, so they are only as safe as the middleware's coverage. Switch to the factories above.
Client components¶
The default import re-exports all React components with 'use client':
See React Integration for hook and component details.
Complete example¶
// duar.config.ts
import type { DuarAuthzMiddlewareConfig } from '@duar-auth/nextjs/authz-middleware'
export const duarConfig: DuarAuthzMiddlewareConfig = {
duarUrl: process.env.DUAR_URL!,
idpJwksUrl: 'https://www.googleapis.com/oauth2/v3/certs',
idpAudience: process.env.GOOGLE_CLIENT_ID!,
idpIssuer: 'https://accounts.google.com',
serviceName: 'my-app',
}
// middleware.ts
import { createDuarAuthzMiddleware } from '@duar-auth/nextjs/authz-middleware'
import { duarConfig } from './duar.config'
export default createDuarAuthzMiddleware(duarConfig)
// AuthZ tokens travel in request headers, which page navigations never carry: the middleware
// guards the API, and pages protect themselves client-side with <AuthzGuard>.
export const config = { matcher: ['/api/:path*'] }
// app/login/page.tsx
'use client'
import { AuthzProvider, useAuthz } from '@duar-auth/nextjs'
import { IdpConfigs } from '@duar-auth/js'
function LoginButton() {
const { login } = useAuthz()
return <button onClick={() => login('google')}>Sign in with Google</button>
}
export default function LoginPage() {
return (
<AuthzProvider config={{
duarUrl: process.env.NEXT_PUBLIC_DUAR_URL!,
idps: { google: IdpConfigs.google(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!) },
}}>
<LoginButton />
</AuthzProvider>
)
}
// app/auth/callback/page.tsx
'use client'
import { AuthzProvider, AuthzCallback } from '@duar-auth/nextjs'
import { useRouter } from 'next/navigation'
export default function CallbackPage() {
const router = useRouter()
return (
<AuthzProvider config={{ duarUrl: process.env.NEXT_PUBLIC_DUAR_URL! }}>
<AuthzCallback onSuccess={() => router.push('/dashboard')} />
</AuthzProvider>
)
}
Server-side checks live in Route Handlers that the browser calls with useAuthzFetch():
// app/api/notes/route.ts
import { createDuarAuthzServer } from '@duar-auth/nextjs/server'
import { duarConfig } from '@/duar.config'
const { withAuth } = createDuarAuthzServer(duarConfig)
export const GET = withAuth(async (req, user) => {
return Response.json({ workspace: user.workspaceId, actions: user.actions })
})