Kanzo UI
Auth

Next.js

The Backend For Frontend for an App Router product as one object — kanzoAuth, whose proxy renews and ends sessions before a page renders, whose routes sign in, out and back-channel, and whose session() a server component reads.

import { kanzoAuth } from "@kanzo-tech/auth/next";

One name over the confidential client, and four files an App Router product already has:

lib/auth.ts
import { kanzoAuth } from "@kanzo-tech/auth/next";
import { ticketStore } from "@kanzo-tech/auth/server";

export const auth = kanzoAuth(async () => ({
  issuer, clientId, clientSecret, secret,
  store: ticketStore(redisAdapter),
  organization: ({ url }) => url.hostname.split(".")[0],
  public: ["/health"],
  api: { mount: "/api", target: process.env.API_URL },
}));
proxy.ts
export const proxy = (request: NextRequest) => auth.proxy(request);
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"] };
app/api/auth/[...auth]/route.ts
export const { GET, POST } = auth.routes;
app/api/[...path]/route.ts
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;

And in any server component:

app/(signed-in)/layout.tsx
const session = await auth.session({ required: true });
if (!can(session, "reader")) forbidden();

kanzoAuth returns a KanzoAuth: proxy, routes, api and session. They are four pieces of one relying party — one issuer, one discovery cache, one store, and one renewal per session that the proxy, the forwarder and the refresh route all join. can comes from the root barrel, exactly as Gate asks in the browser.

There is no requireRole. It would have added no new evaluation of a role — only a throw — and which throw (forbidden(), a redirect, a rendered explanation) is a product's answer rather than a library's. packages/auth/src/next.test.ts is what keeps it absent, rather than this sentence.

Bound on the first request

The config is a function, and it may be async. next build imports every route module with no secrets in the environment, so nothing is read at import: the function runs on the first request that needs it — a secret from a file, a vault, a database — and the instance it builds is kept. A failure is not kept: a deployment that started before its identity provider or its secret was ready recovers on the next request rather than remembering the outage for the life of the process. routes and api are plain handler objects that exist at import time, which is what export const { GET, POST } = auth.routes needs.

The proxy is the session authority

Next 16 runs proxy.ts on Node, always — it is not configurable — so the edge's reason for checking only that a cookie exists is gone. auth.proxy reads the session record, one store read per request, and acts on it before any page renders. This is WorkOS AuthKit's authkitProxy: renewal and enforcement where the URL is known.

The recordA navigationAnything else
live, access token validpasses throughpasses through
live, access token within renewWithin (60 s) of expiryrenewed, then passesrenewed, then passes
absent, forgotten by the store, or the IdP refused to renewcookie cleared, 307 to signin?returnTo=<path+search>cookie cleared, 401, no-store
the store does not answer307 to problemPage?code=session/unavailable503 or 504
the IdP does not answerpasses through untouchedpasses through untouched

The last row is deliberate: an IdP outage has refused nothing, so the session is still the person's, and a page that renders is more use than a sign-in that cannot finish.

What decides between a redirect and a 401 is Sec-Fetch-Mode — W3C Fetch Metadata, set by the browser and not settable by script. navigate is a document load; cors, no-cors and same-origin are a prefetch, a server action, an RSC fetch, a fetch of the product's own. Next strips its own RSC headers before the proxy sees them, so this is the one signal that survives.

A request that is not a navigation cannot follow a sign-in and must not start one: a hovered link that prefetched its way into /api/auth/signin would be a sign-in nobody asked for. It gets a bare 401; the Next router answers a 401 to its own fetch with a hard navigation, which arrives back here as navigate and is sent to sign in carrying its URL. An absent header is a client that is not a browser, or one too old to send it, and is treated as a navigation — a redirect it can follow is the more useful answer.

Renewal in place, with a stable ticket

The ticket in the session cookie is issued once, at sign-in, and names the session for its whole life — Duende BFF's server-side session. A renewal spends the refresh token and writes the new tokens under the same ticket, so with a ticketStore the cookie does not change and the proxy has nothing to hand the browser or the page. The renewal is single-flight per ticket in the process; the server page says what happens when two processes race.

With the stateless store the ticket is the record, so a renewal re-seals it, and the proxy sets the new cookie with response.cookies.set. That call is what makes Next merge the cookie into the same request's cookies(), so the page rendering behind the proxy reads the session the browser will hold. A raw Set-Cookie header would reach the browser and not the page.

The URL, forwarded

Every request leaves the proxy with x-kanzo-url set to the URL it arrived at — AuthKit's x-url — after every incoming x-kanzo-* header is deleted, so a browser cannot choose what a server component reads there. session({ required: true }) signs in back to it, and the tenant resolver reads it. Extra request headers a product needs on the page, such as a CSP nonce, go in the second argument:

proxy.ts
export const proxy = (request: NextRequest) =>
  auth.proxy(request, { headers: { "x-nonce": nonce } });

The matcher

Static files, basePath, api.mount, problemPage and every public prefix pass without a session. public is matched on segment boundaries: /health opens /health/live and not /healthcare. The static-file exemption is applied on the path as well, so it holds whatever matcher a product writes — a matcher that missed static files once served an application's .wasm from the sign-in page — but the matcher above is the one to start from.

session() in a server component

const session = await auth.session();                    // Session | null
const session = await auth.session({ required: true });  // Session, or a redirect to sign in

It reads the cookie through next/headers and is memoised per request through React's cache, so a layout, a breadcrumb and a menu asking in one render unseal the cookie and read the store once. It never renews — a server component cannot write cookies — and it does not need to: the proxy already renewed or ended the session before the page began. { required: true } is AuthKit's ensureSignedIn: on a page the proxy does not run on, or a session that ended mid-render, it redirects to sign in carrying the path and query the proxy forwarded.

Tenancy

organization resolves the tenant a request addresses: a hostname's first label, a path segment, a cookie, or one fixed alias for a deployment per tenant.

organization: ({ url, headers, cookies }) => url.hostname.split(".")[0]

It is handed { url, headers, cookies }, built the same way wherever it runs: the routes from their request, a server component from host and the x-kanzo-url the proxy forwarded. Its answer is Session.organization, on session() and on the /session route, and it is the organization can asks inside by default. The tenant is resolved per request and never stored, which is what lets two tabs sit in two organizations at once. Every sign-in asks Keycloak for organization:*, so membership of every organization arrives in one token.

routes

PathVerbAnswers
/signinGET, POST302 to the authorization endpoint, with this attempt's transaction cookie
/callbackGET, POST302 to where the person was going, with the session cookie
/sessionGET, POSTthe Session as JSON, current tenant included, or 401 and no body
/refreshPOSTthe renewed Session, or 401 with the cookie cleared
/signoutGET, POST302 to the end-session endpoint, clearing the cookie
/backchannel-logoutPOST200, 400, or 501 — below

The callback URL is redirectUri, or derived from the request — the origin it arrived at, the path the routes sit on, and /callback. That origin is not request.url's, which Next builds from the address it listens on: the host is X-Forwarded-Host, else Host, and the scheme X-Forwarded-Proto, else the request's — the headers Auth.js reads. Trusting them is a bounded trust: a forged host produces a redirect_uri Keycloak has not registered, and Keycloak refuses it. A deployment whose proxy sets none of them says the URL out loud.

One transaction per state

Each sign-in attempt seals its state, nonce and PKCE verifier into a cookie of its own, __Host-kanzo-auth.<state>, for ten minutes — Auth0's v4 SDK does the same with __txn_{state}. With one transaction cookie, a second tab that started signing in overwrote the first tab's attempt and the first tab's callback failed; with one per state, both finish, and each callback clears the one it spent.

A failure is a page, not a body

/signin, /callback and /signout are reached through the address bar, so an AuthError there is a 302 to the product's problemPage — default /auth/problem — with the code in ?code=: /auth/problem?code=callback%2Fstate-mismatch. The page is the product's, and renders the code in its own words:

// app/auth/problem/page.tsx
export default async function Page({ searchParams }: { searchParams: Promise<{ code?: string }> }) {
  const { code } = await searchParams;
  return <Problem code={code} />; // the product's own view of a code
}

The proxy keeps problemPage public, because whoever lands there has no session; sending them to sign in instead would loop when signing in is what failed.

/session and /refresh are reached by fetch and answer { error, message } with a status: 401 for session/absent and token/refused, 503 for session/unavailable, 502 for idp/unreachable, 504 for idp/silent, and 400 for a refusal. bffAuth reads only the 401 as nobody is signed in.

/refresh constrains its verb because it spends a refresh token, and a GET that spends something is one prefetch, one link preview or one crawler away from spending it unasked. A GET gets a 405 rather than a 404, because the route is plainly there.

Which routes a cross-site request may reach

The session cookie is SameSite=Lax, and that is not a preference — Strict withholds it on the navigation back from the identity provider, so every sign-in would fail. What Lax costs is exactly one thing: a cross-site top-level navigation still sends the cookie. So <img src="…/api/auth/signout"> on any page anywhere is a logout anyone can cause.

The package that chose Lax is the one that owes the compensation, so the check is here rather than in every product:

RouteFrom a cross-site page
/callbackallowed — it is a cross-site navigation, from the IdP
/signinallowed — starting a flow grants nothing, and "sign in to X" is a link people write
/backchannel-logoutallowed — a server's POST, authenticated by the signature on its token
/session, /refresh, /signout403

Sec-Fetch-Site is read first, because a browser sets it and script cannot. same-origin and none pass — none is a bookmark or a typed URL, which is the person acting. same-site is refused along with cross-site: the cookie is __Host- and therefore host-only, but host-only says where the cookie lives, not who may cause a request to it. Without the header, Origin is compared; without either, the request is allowed, because something carrying no evidence of where it came from also carries no evidence that it came from a page, and a page is the attack.

organization is validated before it becomes a scope

scope is a space-delimited list, so ?organization=acme%20offline_access reaching begin unchecked is not a strange alias — it is a second scope, asking for a refresh token that outlives the browser session, chosen by whoever composed the link. begin refuses anything that is not an alias or *, and this route sends the browser to the problem page with organization/invalid rather than a 500 on a mistyped link.

Back-channel logout

When a session ends at Keycloak — an administrator signs the person out, the SSO session expires, they sign out of another application — Keycloak posts a logout token to the client's backchannel_logout_url, per OpenID Connect Back-Channel Logout 1.0. The route verifies it as §2.6 requires — signature against the realm's keys, iss, aud, iat, the back-channel event, no nonce — and drops every session it names through the store, the way Auth0's SDK does. The next navigation finds no record and signs in.

AnswerWhen
200the sessions the token names are dropped
400the token does not verify, or there is none (token/refused)
501the store cannot end a session from the server (session/irrevocable): the stateless store, or a ticketStore whose adapter has no keys

Point the client at it with backchannel_logout_url.

api

The token-mediating backend: api.mount is forwarded to api.target with the access token as a bearer, renewed in place first when it is within a minute of expiry. Ninety lines every product writes the same way, and one of them is load-bearing in a way that does not look it: the cookie header is not forwarded. Leave it on and the resource server receives a second credential beside the bearer token it asked for, which is the confusion the BFF pattern exists to remove.

What else it does, none of which is a product's idea of its own domain: strips the hop-by-hop headers, forwards the body as a stream with duplex: "half" so an upload is never read into memory and an SSE stream is never buffered until it ends, sets redirect: "manual" so a Location from the upstream cannot send the bearer token to whatever host it names, drops set-cookie from the answer, and drops content-encoding and content-length with it.

target may be a function of the organization the request addresses — the same answer organization gives — for one BFF in front of a resource server per tenant:

kanzoAuth(() => ({
  …,
  organization: (request) => request.url.hostname.split(".")[0],
  api: { mount: "/api", target: (organization) => `http://${organization}-api:8080` },
}));

That is Keycloak's Organizations model: one client shared by every organization, with the tenant in the token, so one sign-in, one session store and one back-channel logout URL serve them all, while each tenant's data stays behind its own resource server. A request that addresses no organization — a bare host — gets undefined from the function, and api answers it 404.

It answers 401 with the cookie cleared when there is no session or the renewal was refused, 403 when the request is not same-site, 400 for a path outside its mount, and 404 when kanzoAuth was given no api. The proxy leaves api.mount alone.

The upstream is reached with the global fetch, not with the config's fetch. That field exists to reach the identity provider — it is the seam internalOrigin uses to come at Keycloak from inside a cluster — and a deployment that set it and found its resource-server traffic going the same way would have every right to be surprised.

Where each mechanism comes from

Nothing here is invented; each piece is a reference system's answer or a standard's.

MechanismReference
Tokens on the server, a cookie in the browserRFC 10017, Backend For Frontend
One client object for proxy, routes and sessionAuth0 Next.js SDK v4, Auth0Client
Renewal and enforcement in the proxy; the URL forwarded to the page; requiredWorkOS AuthKit, authkitProxy, x-url, ensureSignedIn
A stable ticket, tokens updated in placeDuende BFF, server-side sessions
A transaction cookie per stateAuth0 Next.js SDK v4, __txn_{state}
Redirect or 401 by Sec-Fetch-ModeW3C Fetch Metadata
Logout token verification, 200 / 400 / 501OpenID Connect Back-Channel Logout 1.0 §2.6, §2.8
Back-channel logout through the session storeAuth0 Next.js SDK v4

API Reference

kanzoAuth

kanzoAuth(config: KanzoAuthConfig | (() => KanzoAuthConfig | Promise<KanzoAuthConfig>)): KanzoAuth.

KanzoAuthConfig is RelyingPartyConfig plus:

PropTypeDefault
organization({ url, headers, cookies }) => string | undefined | Promise<…>no current tenant
basePathstring/api/auth
problemPagestring/auth/problem
publicreadonly string[]nothing
redirectUristringderived from the request
renewWithinnumber60, in seconds
api{ mount: string; target: string | ((organization: string | undefined) => string | undefined) }none; api answers 404

KanzoAuth

MemberType
proxy(request: NextRequest, options?: { headers?: HeadersInit }) => Promise<NextResponse>
routes{ GET, POST }, each a web Request in and a web Response out
api{ GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }, likewise
session() => Promise<Session | null>, and ({ required: true }) => Promise<Session>

On this page