@bulwarkauth/nextjs

Next.js SDK for Bulwark — middleware, Server Component helpers, cookie-based session management, and route handler utilities.

Installation

npm install @bulwarkauth/nextjs
# or
pnpm add @bulwarkauth/nextjs

Entry points

| Import path | Use for | |-------------|---------| | @bulwarkauth/nextjs | Client components ("use client") and provider | | @bulwarkauth/nextjs/server | Server Components, Server Actions, route handlers | | @bulwarkauth/nextjs/middleware | middleware.ts |


Provider

CookieAwareBulwarkProvider

A wrapper around BulwarkProvider that stores tokens in HTTP cookies so Server Components can read them without client-side hydration.

Add it to your root layout:

// app/layout.tsx
import { CookieAwareBulwarkProvider } from "@bulwarkauth/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <CookieAwareBulwarkProvider publishableKey="pk_live_...">
          {children}
        </CookieAwareBulwarkProvider>
      </body>
    </html>
  );
}

Props

| Prop | Type | Required | Description | |------|------|----------|-------------| | publishableKey | string | Yes | Your pk_live_ or pk_test_ key |


Re-exports from @bulwarkauth/react

The following are re-exported directly from @bulwarkauth/nextjs and work identically to their React counterparts:

Components: SignIn, SignUp, UserButton, UserProfile

Hooks: useAuth, useMFA, usePasskey, useBulwark, useAppConfig

All must be used inside a Client Component ("use client").

// app/login/page.tsx  — this is a Server Component, SignIn renders client-side
import { SignIn } from "@bulwarkauth/nextjs";

export default function LoginPage() {
  return <SignIn signUpUrl="/signup" />;
}

Server helpers (@bulwarkauth/nextjs/server)

All server helpers read the session from cookies set by CookieAwareBulwarkProvider. They are safe to call in Server Components, Server Actions, and route handlers.

auth()

Clerk-style shorthand for getSession() (see below) — returns the verified session claims, or null if not authenticated. The primary helper for Server Components.

// app/dashboard/page.tsx
import { auth } from "@bulwarkauth/nextjs/server";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect("/login");
  }

  return <h1>Welcome, {session.email}</h1>;
}

getSession()

Reads the access-token cookie and returns its verified claims ({ sub, tenant_id, email, roles, exp, iat } — see BulwarkClaims), or null if there's no token, the token fails signature/tenant verification, or it's expired. A JWKS outage is also collapsed to null here (but logged via console.error) — use requireSession() instead if you need to tell "not signed in" apart from "Bulwark is temporarily unreachable".

import { getSession } from "@bulwarkauth/nextjs/server";

const session = await getSession();
// session.sub, session.tenant_id, session.email, session.roles, session.exp

getUser()

Alias for getSession() — same return value, same null-on-signed-out/outage behavior. (Despite the name, it returns the verified claims object, not a separate User profile — for the full profile fetched from the Bulwark API, see currentUser() below.)

import { getUser } from "@bulwarkauth/nextjs/server";

const user = await getUser();

currentUser()

Verifies the session first, then fetches the full user profile from /api/v1/auth/me using the access token as a Bearer credential. Returns the profile object, or null — not a throw — if there's no session, the session fails verification, or the profile fetch itself fails or returns a non-2xx. If you write a Server Action assuming this throws when signed out, it will instead run with user bound to null.

import { currentUser } from "@bulwarkauth/nextjs/server";

// In a Server Action:
export async function updateProfile(data: FormData) {
  "use server";
  const user = await currentUser();
  if (!user) {
    throw new Error("Not authenticated");
  }
  // ...
}

requireSession()

Returns the verified session claims (the same BulwarkClaims shape as getSession()) directly — not wrapped in { user, session }. Throws a plain Error("Authentication required") if there's no token or it fails verification. Unlike getSession()/auth(), a JWKS-outage error (BulwarkVerificationUnavailableError) is not collapsed here — it propagates so a genuinely-signed-in user hitting a Bulwark outage can be told "temporarily unavailable" instead of being treated as logged out.

import { requireSession } from "@bulwarkauth/nextjs/server";

export async function GET() {
  const session = await requireSession();
  return Response.json({ id: session.sub });
}

Route handler wrapper

withBulwarkAuth(handler, options?)

Wraps a Next.js route handler to require authentication. Attaches the verified session claims to request.bulwarkUser (a BulwarkClaims — sub, tenant_id, email, roles, exp, iat) rather than passing them as a second handler argument.

// app/api/profile/route.ts
import { withBulwarkAuth } from "@bulwarkauth/nextjs/server";

export const GET = withBulwarkAuth(async (request) => {
  const user = request.bulwarkUser;
  return Response.json({ id: user.sub, email: user.email });
});

// Require a specific role:
export const DELETE = withBulwarkAuth(
  async (request) => {
    // request.bulwarkUser is available here too
    return new Response(null, { status: 204 });
  },
  { roles: ["admin"] }
);

Options

| Option | Type | Description | |--------|------|-------------| | roles | string[] | Roles required to access this handler. Returns 403 if any role is missing |


Middleware

createBulwarkMiddleware(config)

Creates a Next.js middleware function that protects routes. When the short-lived access token has lapsed it silently refreshes the session (see Session continuity below) and only redirects to the login page when no valid session can be recovered.

// middleware.ts
import { createBulwarkMiddleware } from "@bulwarkauth/nextjs/middleware";

export default createBulwarkMiddleware({
  // Safe default: protectedPaths: ["/"] protects EVERY route, and
  // publicPaths lists the specific exceptions.
  protectedPaths: ["/"],
  // "/api/auth/*" — a wildcard, not the plain "/api/auth" — since
  // publicPaths matches plain patterns EXACTLY (see "Path matching"
  // below): a callback/session/etc. sub-route under /api/auth needs the
  // explicit subtree form to stay public too.
  publicPaths: ["/", "/login", "/signup", "/api/auth/*"],
  loginPath: "/login",
  requiredRoles: [],          // optional global role requirement
  // Required for silent refresh AND for JWKS signature verification, which
  // is ALWAYS on (see "JWT verification" below) — falls back to
  // NEXT_PUBLIC_BULWARK_API_URL/BULWARK_API_URL when omitted.
  bulwarkApiUrl: process.env.NEXT_PUBLIC_BULWARK_API_URL, // e.g. https://api.bulwarkauth.com
  // Required unless dangerouslySkipTenantCheck is set — falls back to
  // BULWARK_ALLOWED_TENANT_IDS/BULWARK_TENANT_ID when omitted. See "JWT
  // verification" below for why this can't be defaulted for you.
  allowedTenantIds: [process.env.BULWARK_TENANT_ID!],
});

export const config = {
  // ⚠️ Matcher exclusions are REGEX PREFIX matches — any path they skip
  // gets NO auth from this middleware. Exclude only exact static asset
  // paths, never a broad prefix or extension pattern — even
  // `_next/static/` alone is a prefix (e.g. /_next/static/settings, not
  // a real file, would otherwise fall through unauthenticated).
  //
  // Residual gap: a request like /_next/static/x.js that ISN'T a real
  // file can still render a ROOT catch-all route (or any route that's
  // dynamic all the way down to its last segment) — such pages must
  // call requireSession()/getSession() themselves; the matcher alone
  // cannot close this for every possible route shape. A root catch-all's
  // params are ATTACKER-CHOSEN: /_next/static/../../acme/proj/x.png
  // reaches a [...all] catch-all with all === ["acme", "proj", "x.png"],
  // exactly as if the attacker had requested /acme/proj/x.png directly —
  // never trust the segments as evidence a real static asset was meant.
  matcher: [
    "/((?!_next/static/.+\\.(?:js|css|map|woff2?|ttf|otf|eot|png|jpe?g|gif|svg|webp|avif|ico|txt|json)$|_next/image(?:/|$)|favicon\\.ico$).*)",
  ],
};

Config options

| Option | Type | Default | Description | |--------|------|---------|-------------| | protectedPaths | string[] | [] | Paths (subtree-matched — see below) that require authentication. [] protects EVERY route not covered by publicPaths. "/" (or an unscoped "/*"/"/**") protects ALL routes. Every entry must be a literal, plain-ASCII path (see below) — no %, non-ASCII/space, ./.. segments, \, (/), or a leading : in a segment; * only as a trailing /*//** suffix. | | publicPaths | string[] | [] | Paths (EXACT-matched — see below) explicitly exempt from protection — checked BEFORE protectedPaths. "/" here means ONLY the literal root page, never "everything". An unscoped "/*"/"/**" throws at construction. Same literal-ASCII-path rule as protectedPaths; a "/x/*"/"/x/**" wildcard here matches STRICT DESCENDANTS of /x only, never /x itself (see "Path matching" below). | | loginPath | string | "/login" | Redirect destination for unauthenticated requests. Must start with "/" — validated at construction. Same literal-ASCII-path rule as the lists above, PLUS no * at all (it names one page, not a pattern). Rejects paths containing ?, #, or starting with // (throws at construction — use publicPaths/query params for that instead of baking them into loginPath); a single trailing / is stripped. Implicitly public (exact match, both raw and decoded forms — see "Path matching" below), so an empty {} config can still reach it without redirect-looping. With Next's own trailingSlash: true config, set this to "/login/" (with the trailing slash) — otherwise Next's router adds its own 308 redirect from /login to /login/ AFTER this middleware's redirect, an extra hop on every unauthenticated request. | | requiredRoles | string[] | [] | Roles required on all protected paths | | bulwarkApiUrl | string | NEXT_PUBLIC_BULWARK_API_URL/BULWARK_API_URL — no hosted-default fallback | Your Bulwark API origin. Required — used for silent token refresh AND is where every access token's signature is verified against Bulwark's JWKS (/.well-known/jwks.json); verification is always on, not something this option "enables". createBulwarkMiddleware throws AT CONSTRUCTION if neither this nor an env var resolves — it will never silently fall back to Bulwark's hosted origin, which would send a self-hosted deployment's users' refresh/access tokens to the wrong server. | | allowedTenantIds | string[] | BULWARK_ALLOWED_TENANT_IDS/BULWARK_TENANT_ID env vars | Tenant id(s) a verified token's tenant_id must match. Required (one way or another) unless dangerouslySkipTenantCheck is set — see "JWT verification" below. | | dangerouslySkipTenantCheck | boolean | false | ⚠️ Accepts a verified token from ANY Bulwark tenant. See "JWT verification" below. | | dangerouslySkipSignatureVerification | boolean | false | ⚠️⚠️ Reverts to an unverified local decode (only exp is checked). See "JWT verification" below. |

Safe default (used above): protectedPaths: ["/"] protects EVERY route, and publicPaths lists the specific exceptions. The alternative — a non-empty protectedPaths WITHOUT "/" — protects ONLY the paths listed (and their subtrees), leaving everything else public by default; only reach for that shape if you deliberately want an allowlist of protected routes rather than the reverse.

Path matching (final semantics as of round 11): protectedPaths and publicPaths are matched DIFFERENTLY, on purpose.

  • protectedPaths: a plain pattern P matches P itself AND its subtree — pathname === P || pathname.startsWith(P + "/") — so "/dashboard" matches /dashboard and /dashboard/settings, but never /dashboardx. "/" (or an unscoped "/*"/"/**", with no scoping segment) protects every route. A "/x/*"/"/x/**" wildcard ALSO matches the base "/x" — the fail-closed direction.
  • publicPaths: a plain pattern matches EXACTLY — "/login" exempts only /login, never /login/anything. A public SUBTREE requires an EXPLICIT wildcard, "/docs/*" or "/docs/**" — but this matches STRICT DESCENDANTS of /docs ONLY (round 11, a fail-closed correction); it does NOT also make /docs itself public. With only a docs/[...slug] catch-all defined, Next.js can route a request for /docs itself to a COMPLETELY DIFFERENT dynamic route than the one the wildcard's descendants hit — the same bug class as the /lo%67in example below. List the base explicitly (publicPaths: ["/docs", "/docs/*"]) if you want it public too. "/" exempts ONLY the literal root page (pathname === "/") — never "everything" — and an unscoped "/*"/"/**" throws at construction rather than silently doing the same. A trailing "/" on any configured pattern is normalized away — and so, separately, is exactly ONE trailing "/" on the INCOMING request's own path before matching (so a trailingSlash: true deployment's /login/ still matches a configured /login, rather than looping a redirect back to itself forever).

This split is deliberate: consumers rely on publicPaths being exact (round 5 briefly made it subtree-matched too, which would have silently WIDENED any app relying on exact public matching — e.g. a publicPaths: ["/accept-invitation"] also exempting /accept-invitation/[token]/...). protectedPaths staying subtree-matched fixes the ORIGINAL bug (pre-0.5.0): a plain /dashboard never protected /dashboard/settings at all.

Every pattern (a protectedPaths/publicPaths entry, or loginPath) must be a literal, plain-ASCII path (round 11). Construction throws if it contains "%", a non-ASCII character or space, a "."/".." segment, "\", "("/")", or a segment starting with ":". This isn't a glob engine, a regex engine, or Next.js's own matcher syntax — a "*" is meaningful ONLY as a trailing "/*"/"/**" wildcard suffix on the WHOLE entry, nowhere else, and loginPath allows no wildcard at all (it names one specific page). Patterns that look like glob/regex/ Next-matcher/route-param syntax — /dashboard/:path*, /dashboard(.*), /x/*/y, /x*, /a*b — throw at construction rather than being silently accepted as a dead, never-matching literal string, which in protectedPaths would have protected nothing at all (a non-empty protectedPaths leaves everything it doesn't match public). There is also no way to write a working non-ASCII/space public path: real Next.js production never serves a static route at that kind of URL — it 404s, or falls through to a different, still-protected dynamic route. Use a plain ASCII slug.

A route is matched against BOTH the raw (still-encoded) pathname and a per-segment-DECODED one — never the decoded one alone. Next.js itself routes on the RAW pathname for STATIC segments (only a DYNAMIC segment's value gets decoded before your page sees it) — so an encoded alias of a real public path, /lo%67in, does NOT route to a static app/login; it routes to a totally different DYNAMIC sibling (e.g. app/[org], with org: "login"). Because every pattern is guaranteed ASCII (no "%"), its raw and decoded forms are simply the same literal string — a request is PUBLIC only if BOTH its raw and decoded forms match that SAME configured entry; a path is PROTECTED if EITHER form matches a protected pattern, or if decoding fails outright. This is the wider/safer rule for protectedPaths — under-protecting is the risk there rather than over-exposing.

⚠️ Protecting a dynamic param VALUE is not a security boundary. protectedPaths: ["/acme/billing"] under an app/[org]/billing route only blocks that one literal string — /ACME/billing (different case, a whole separate dynamic-route match) or any other value that resolves to the same route reaches the page unauthenticated. Protect the parent dynamic segment (everything under [org]) or authorize inside the page/route handler instead of trying to allowlist specific param values.

Earlier — pre-0.5.0 — versions ALSO treated "/" as "match everything" in BOTH lists, which combined with public-first precedence made publicPaths: ["/", ...] — a pattern formerly documented on this very page — expose every protected route with no auth check and no warning. If you're upgrading from that far back and relied on "/" in publicPaths meaning "everything", list your actual public routes explicitly instead.

The Bulwark API URL must be the final, canonical origin. This SDK refuses to follow a redirect from bulwarkApiUrl — the JWKS fetch, silent refresh, and currentUser()'s /me fetch all use redirect: "manual" and treat any 3xx as an outage. A URL that itself redirects (apex→www, http://→https://, ...) makes every request fail as service_unavailable/503 — point bulwarkApiUrl at the origin your server actually terminates TLS on.

next/image under protect-everything (protectedPaths: ["/"]) — round 12 correction: this note previously blamed the wrong component. The matcher EXCLUDES /_next/image ITSELF (see the matcher above), so this middleware never runs on the optimizer's OWN request — but the matcher does NOT exclude the src path (e.g. /images/foo.png). Next's image-optimizer SERVER makes its own internal fetch to that src path to read the original file, and THIS MIDDLEWARE runs on that internal fetch exactly like any other request to the app. That fetch carries NO browser cookies (it isn't the visitor's browser making it), so if src is gated by protectedPaths: ["/"], the middleware sees no session on it and redirects — the optimizer then can't read a real image out of that redirect response and returns 400 ("The requested resource isn't a valid image...") to EVERY visitor, including signed-in ones, not just signed-out ones. Confirmed live: adding publicPaths: ["/images/*"] (a middleware-only setting) turns this back into a 200. There is no way to make a next/image src work through a protected path at all: list its source path in publicPaths — a protected image cannot be served through next/image, only unoptimized/directly.

JWT verification

Every access token — on the incoming request, and any freshly-minted one returned by silent refresh — is verified against Bulwark's published JWKS (EdDSA/Ed25519, offline, no extra round-trip per request beyond the periodically-refreshed JWKS itself). This applies to createBulwarkMiddleware, withBulwarkAuth, and the Server Component helpers (getSession, getUser, auth, requireSession) alike.

createBulwarkMiddleware() resolves its OWN verifier, independently from the Server Component helpers' shared one. They don't share a runtime instance (the Edge Runtime middleware and Node Server Components don't even share a process in most deployments) — each resolves bulwarkApiUrl/allowedTenantIds from its own config, falling back to the SAME environment variables. In practice this changes nothing for the common case (env vars, read identically by both); it only matters if you call configureBulwarkServer() programmatically with settings that differ from what you pass createBulwarkMiddleware(). See the SDK README's "Which config does each helper use?" for the full explanation.

Because Bulwark's JWKS is a single instance-wide key set, not scoped per tenant, a validly-signed token minted for a completely different Bulwark customer's app is otherwise indistinguishable from one minted for yours — so a tenant allowlist is required, resolved in this order:

  1. allowedTenantIds (middleware config) — a string[].
  2. BULWARK_ALLOWED_TENANT_IDS — a comma-separated list (e.g. tenant-a,tenant-b for an app fronting more than one tenant).
  3. BULWARK_TENANT_ID — a single tenant id.

If none of these resolve, verification throws loudly rather than accepting every tenant by default.

Shared-isolation-mode apps: a token's tenant_id carries the parent WORKSPACE id, not any per-app id. Set BULWARK_TENANT_ID (or allowedTenantIds) to that workspace id, not an application id.

Two escape hatches exist for migrating an existing deployment onto @bulwarkauth/[email protected] without finishing tenant/JWKS configuration in the same change — both log a warning once per process when enabled, and neither is recommended beyond that migration window:

  • dangerouslySkipTenantCheck (or BULWARK_DANGEROUSLY_SKIP_TENANT_CHECK=true) — accepts a verified token from any tenant.
  • dangerouslySkipSignatureVerification (or BULWARK_DANGEROUSLY_SKIP_SIGNATURE_VERIFICATION=true) — reverts to the pre-0.5.0 unverified local decode entirely (only exp is checked; any caller can forge a token with an arbitrary sub/roles/tenant_id).

Both env vars are read as exact strings — only "true" enables the flag; unset, "", or "false" leaves it disabled; any other value ("1", "yes", stray whitespace, wrong case, …) throws rather than guessing.

A JWKS outage (the endpoint unreachable, not a bad token) is never treated as "every token is invalid": withBulwarkAuth returns 503 (distinct from 401), getSession/getUser/auth collapse it to null but log it via console.error, requireSession() lets it propagate as a BulwarkVerificationUnavailableError (import from @bulwarkauth/nextjs/server) so a caller can render a "temporarily unavailable" state, and the middleware page gate redirects to loginPath with ?error=service_unavailable appended — without clearing the session cookies (an outage may be transient; a real logout is not forced on it).

Session continuity (silent refresh)

Bulwark access tokens are short-lived (~15 minutes) by design, while refresh tokens last days (7 by default). On every protected navigation the middleware runs before your page, so when the access-token cookie (bulwark_token) has expired or been dropped, the middleware exchanges the refresh-token cookie (bulwark_refresh) for a fresh token at POST {bulwarkApiUrl}/api/v1/auth/refresh and lets the request continue — instead of bouncing the user to /login every ~15 minutes.

This requires bulwarkApiUrl (or its env-var equivalent) to be set — createBulwarkMiddleware throws at construction otherwise, never silently falling back to Bulwark's hosted origin. The exchange is transparent: refresh tokens rotate (each is single-use), so the middleware writes both the new bulwark_token and the rotated bulwark_refresh back onto the response. If the refresh token is DEFINITIVELY rejected (a 401/403 from the refresh endpoint, or a refreshed token that itself fails verification), both cookies are cleared and the user is redirected to loginPath. A refresh-endpoint OUTAGE (5xx, timeout, network failure, or a malformed 2xx) is treated the same as a JWKS outage instead — redirected with ?error=service_unavailable, cookies left untouched — never a hard logout for what might be transient.

Available since @bulwarkauth/[email protected]. Earlier versions redirected to login as soon as the access token lapsed, regardless of refresh-token validity.

Breaking change in @bulwarkauth/[email protected]: every access token is now verified against Bulwark's JWKS instead of being locally decoded unverified (see "JWT verification" above) — a deployment that doesn't configure allowedTenantIds/bulwarkApiUrl (or their env-var equivalents) now throws loudly instead of silently accepting forged tokens. Set BULWARK_TENANT_ID (most apps already have NEXT_PUBLIC_BULWARK_API_URL set for currentUser()) and you're done; use dangerouslySkipSignatureVerification/ dangerouslySkipTenantCheck only as a temporary bridge while migrating.


Cookie management

These utilities are used internally by CookieAwareBulwarkProvider but are exported for advanced use cases such as custom login flows in route handlers.

import {
  setAuthCookies,
  clearAuthCookies,
  getTokenFromCookies,
} from "@bulwarkauth/nextjs/server";

setAuthCookies(cookieStore, tokens, options?)

Sets the bulwark_token (access) and bulwark_refresh cookies on a cookie store — typically the cookies() store from next/headers. Tokens use snake_case keys (access_token, refresh_token), matching the Bulwark API response. Returns a Promise.

import { cookies } from "next/headers";
import { setAuthCookies } from "@bulwarkauth/nextjs/server";

// In a route handler or Server Action after a custom login:
export async function POST(req: Request) {
  const { access_token, refresh_token } = await performLogin(req);
  const cookieStore = await cookies();
  await setAuthCookies(cookieStore, { access_token, refresh_token });
  return Response.json({ ok: true });
}

options accepts the standard cookie attributes (httpOnly, secure, sameSite, path, maxAge); the defaults are httpOnly: false, secure: true, sameSite: "lax", path: "/" with a 15-minute access-token maxAge and a 7-day refresh-token maxAge. httpOnly defaults to false deliberately — the browser-side SDK reads both cookies via document.cookie to send a Bearer header to a cross-origin API, and an httpOnly cookie is invisible to it. If your setup is same-origin and server-rendered only, pass { httpOnly: true } explicitly.

clearAuthCookies(cookieStore)

Clears both auth cookies (sets them with maxAge: 0) on the given cookie store. Called automatically by UserButton sign-out.

getTokenFromCookies(cookieStore)

Returns the raw access token string (the bulwark_token cookie) from the given cookie store, or null. Useful for passing the token to external services.


Example: complete middleware.ts

import { createBulwarkMiddleware } from "@bulwarkauth/nextjs/middleware";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export default createBulwarkMiddleware({
  // Safe default: protectedPaths: ["/"] protects EVERY route, and
  // publicPaths lists the specific exceptions.
  protectedPaths: ["/"],
  publicPaths: [
    "/",
    "/login",
    "/signup",
    "/api/auth/*", // a wildcard — publicPaths matches a plain pattern EXACTLY
    "/api/health",
  ],
  loginPath: "/login",
  // Enables silent refresh; JWKS verification is always on and falls back to
  // the same env vars if this is omitted. Set this to keep users signed in
  // past the short access-token TTL.
  bulwarkApiUrl: process.env.NEXT_PUBLIC_BULWARK_API_URL,
  // Required — see "JWT verification" above.
  allowedTenantIds: [process.env.BULWARK_TENANT_ID!],
});

export const config = {
  // ⚠️ Matcher exclusions are REGEX PREFIX matches — any path they skip
  // gets NO auth from this middleware. Exclude only exact static asset
  // paths, never a broad prefix or extension pattern — even
  // `_next/static/` alone is a prefix (e.g. /_next/static/settings, not
  // a real file, would otherwise fall through unauthenticated). See the
  // "Config options" and matcher note above for the full explanation.
  matcher: [
    "/((?!_next/static/.+\\.(?:js|css|map|woff2?|ttf|otf|eot|png|jpe?g|gif|svg|webp|avif|ico|txt|json)$|_next/image(?:/|$)|favicon\\.ico$).*)",
  ],
};

Safe default (used above): protectedPaths: ["/"] protects EVERY route, and publicPaths lists the specific exceptions. See "Path matching" under Config options for the full protectedPaths/publicPaths matching rules.

Deploying in a pnpm monorepo

@bulwarkauth/nextjs is verified consumable from a pnpm monorepo as of 0.4.1 (earlier versions leaked the pnpm-only workspace:* protocol into the published peer dependencies — upgrade to ^0.4.1). One additional gotcha lives entirely on the consumer side but surfaces as the same Module not found: Can't resolve '@bulwarkauth/nextjs' error, so it's worth knowing:

Stale nested node_modules shadowing the install. If you deploy by rsyncing the repo into a persistent directory on the host and building the image there (rather than from a clean checkout), a bare node_modules line in .dockerignore only matches the build-context root. A stale apps/web/node_modules left over from a previous deploy is therefore not excluded, and a COPY apps/web/ … drags it into the image, shadowing the fresh in-image pnpm install. It stays invisible until a dependency version changes — then the bundler resolves the stale copy and fails with Module not found.

Fix: use **/node_modules in .dockerignore so nested node_modules directories are excluded at every level, not just the context root.

This is independent of the SDK — it affects any package in that deploy shape — but an SDK version bump is a common trigger, since a changed dependency version is exactly what un-shadows the stale copy.