Kanzo UI
The design of Kanzo UI

Hardening the auth layer

What @kanzo-tech/auth does so that a script on the origin, a hung API, a request with no origin or a forged header does not become an API call; what moved to the platform's edge; the one session reader; the realm baseline; and the limits it states instead of fixing, each with what would reverse it.

The auth layer is why the package is shaped as it is. This page is what the layer does to resist attack, and what it says it does not do; how it fails is the other half. The identity provider is always Keycloak (the decision), so every rule below leans on Keycloak's own feature where it has one, and none of them is written for a provider the package does not support.

Status: built, but for the realm baseline. The other rules below hold in the tree. Five of them were written for a package that was its own relying party — a required store, a gated dotted path, a checked logout token, two session readers and a test double, a renewal race between web hosts — and each is replaced by the sentence that replaced it when the relying party moved to the platform's edge (the decision). The Breaking list at the end is what a consumer edits; the last section says how far each reference was read.

A script on the origin is API access, so no inline script runs

auth.proxy sets a per-request nonce, and the policy it sends names that nonce and 'strict-dynamic' in script-src and never 'unsafe-inline'. The host gives the other directives:

export const auth = kanzoAuth(async () => ({
  /* … */
  csp: {
    "default-src": ["'self'"],
    "script-src": ["'wasm-unsafe-eval'"], // extras; the package adds the nonce and strict-dynamic
    "worker-src": ["'self'"],
    "connect-src": ["'self'"],
  },
}));

proxy draws 128 random bits per request, writes Content-Security-Policy on the request it forwards (the header Next reads to put the nonce on its own scripts) and on the response, and forwards the nonce as x-nonce for a server component that needs it. A browser without CSP Level 3 support is not served: the policy fails closed, and the script is blocked. The nonce is not auth, and it stays on proxy because that is where a request's headers are written; after the edge took the session it is proxy's one job besides forwarding the URL.

The failure it answers. The edge turns the person's cookie into a token on every request, and the forwarder attaches that token as a bearer to every /api/* request the page makes (packages/auth/src/next-proxy.ts). HttpOnly and a BFF keep the token from being exfiltrated, not from being used: a stored cross-site script in anything the product renders (a graph's name, a dataset's label, an AI answer) calls the API as the person. keasy's script-src carried 'unsafe-inline', and its own comment says an XSS on this origin is API access and puts the fix with the package.

The reference is CSP Level 3's nonce with strict-dynamic, which Next's CSP guide builds on. Neither was read for this page; the mechanism is stated from the package's own hook.

What it costs. A nonce is per request, so a page that carries one cannot be cached as static HTML (Next's own consequence, not re-read here): the product's pages render per request. style-src is the host's and not the package's: Ark's inline styles stay allowed there, and style injection is a lesser, unaddressed risk.

Unproved, and gating. That DuckDB-WASM's worker and its .wasm compile load under strict-dynamic with 'wasm-unsafe-eval' and an explicit worker-src, since strict-dynamic makes browsers ignore host allow-lists for scripts. keasy's end-to-end run over a graph load, an Ask and a chart is that measurement.

What would reverse it: Next shipping nonces natively, so proxy only forwards and the helper goes; or a load that cannot run under strict-dynamic and has no nonce to be given, which returns the product to a policy it owns and writes its risk beside it.

Held by packages/auth/src/next-auth.test.ts, "sends a fresh nonce on every request and names it in script-src with strict-dynamic, without unsafe-inline" and "refuses unsafe-inline in script-src when the config is read".

The forwarder bounds its hop and ends it when the person leaves

The forwarder waits for the upstream through the kernel's within, with the request's own signal: bounded until the upstream's headers, and a bound that fires is api/silent, 504. It passed no signal at all once, so a hung handler held the application's connection until the HTTP client's own timeouts (not looked up), and a person navigating away cancelled nothing upstream: N tabs against one hung API exhaust the web container's sockets. Two rules of the failure page apply to it: the party that makes the request bounds it, and the caller's signal is passed down.

The bound is on headers, not on the body: a server-sent event stream or an upload is alive after the headers and is not the forwarder's to end on a clock. The client's abort still cancels it. There is no per-mount option: an API that is legitimately slower to its headers is the evidence that adds one.

The reference is fossil's rule that every async function crossing a boundary takes { signal } and that an inner wait expires before the one around it (its design/failure.mdx, G1, read).

What would reverse it: an upstream proxy that already enforces both for every mount, so the forwarder's bound is a second one; or the per-mount option above.

Held by packages/auth/src/next-proxy.test.ts, "answers 504, as a problem naming api/silent, when the API sends no headers within the bound", "aborts the upstream request when the client's signal aborts" and "does not end a response body on the clock once the headers have arrived".

A request that says nothing about where it came from is refused

isSameSite fails closed: a request to the forwarder with neither Sec-Fetch-Site nor Origin is refused, and so is one from another site, a sibling subdomain included. Only the browser calls /api/* through the application, and there is no option to say otherwise. The edge's cookie is SameSite=Lax — Strict would withhold it on the navigation back from Keycloak — so a cross-site top-level navigation still carries it, and the edge turns it into a token. The check is the application's and not the edge's: the edge authenticates a request, and whether one of the application's own pages caused it is a question about the application's endpoints. A browser sends one of the two headers on a same-origin request; the absence is a curl, a health probe, a stripped proxy, or an attacker's control. Origin: null, which the comparison fails, stays refused. A script, a probe or a test runner that needs the API calls it at its own origin with its own credential.

The reference is the OWASP CSRF Prevention Cheat Sheet (read): Fetch Metadata is a lightweight and reliable method, falling back to Origin is mandatory, and for sensitive endpoints treat absence as unknown and block. RFC 10017 §6.1.3.3 (read) requires a BFF to implement a proper CSRF defense and says SameSite is not sufficient where same-site neighbours exist, which isSameSite refuses.

Not taken: a nonBrowserClients option. It would have made the unsafe default one flag away and a second mechanism to test; the owner's decision (15 October 2026) is that nothing but the browser calls the application's /api/*, so the option has no caller. Not taken either: the RFC's custom request header, forcing a preflight, which answers the missing-signal case for browsers and adds a mechanism where the signals already decide.

What would reverse it: a real browser population that sends neither header on a same-origin POST, none known, which would send this to the custom header; or a first non-browser caller of /api/* that cannot call the API directly, which brings the option back with that caller as its test.

Held by packages/auth/src/same-site.test.ts, "refuses a request with neither Sec-Fetch-Site nor Origin" and "refuses a sibling subdomain, which the cookie prefix does not cover"; packages/auth/src/next-proxy.test.ts, "answers 403 without asking upstream when the request carries neither Sec-Fetch-Site nor Origin".

The session store is the edge's

The session lives in the edge's store, auth-valkey, behind an opaque host-only cookie, for every application; the application holds no session, no store, no client secret and no refresh token. It replaced a rule that required a ticketStore whenever apis was configured, because the stateless default sealed the whole record into a cookie — 6,407 bytes against the 4,096 a browser keeps, failing at the callback for the person in many organizations — and could not revoke. The edge's cookie is 176 bytes, measured.

Idle timeout and step-up are Keycloak's policy and are not designed here. The realm's clocks are the policy (sso_session_idle_timeout 30 min, sso_session_max_lifespan 10 h), which is what OWASP ASVS 5.0 V7 asks for at 7.3.1 and 7.3.2 by reference to the IdP. A terminated session is unusable (7.4.1) at the edge the moment the person signs out there, and within the realm's access_token_lifespan when the session ends at Keycloak itself: the edge learns it at its next renewal. Not met: re-authentication before a sensitive change (7.5.1), because Session carries no auth_time or acr. Keycloak does it with max_age and acr through an authentication flow, and Session may carry the two claims as Keycloak writes them the day a product has a step-up.

What would reverse it: an application that must hold state about the session the edge cannot — which no product has asked for; or a requirement that a sign-out at Keycloak end the session at once, which is the edge's to answer (a back-channel logout receiver), not this package's.

Held by packages/auth/src/index.test.ts, "are the session reader and its claim reader on the server door".

Every path is behind the edge

The edge gates every request it fronts, a page, a static file or a dotted slug alike; the application's proxy lets every request through and decides nothing about the session. It replaced a proxy that was the session authority itself and let through, unread, a path whose last segment ended in a static extension (staticExtensions) or sat under a public prefix, after a version that let any dotted path through ungated. Both options went with it. A path that must open without a session is the edge's to list (OAUTH2_PROXY_SKIP_AUTH_ROUTES), and a page behind such a path that needs the person asks session({ required: true }), which sends to the edge's sign-in.

What would reverse it: nothing in this package; the list of open paths is the edge's configuration.

Held by packages/auth/src/next-auth.test.ts, "decides nothing about the session: a request with no token goes through" and "answers null with no token, and redirects to the edge's sign-in back to the page when required".

No logout token reaches the application

The application receives no back-channel logout: oauth2-proxy has no receiver for one, and a sign-out at Keycloak itself reaches the edge when the access token next needs renewing, within the realm's access_token_lifespan. It replaced a back-channel logout route in the package, which capped its body at 8 KB, required logout+jwt as typ, exp and jti, and refused a replayed jti (OpenID Connect Back-Channel Logout 1.0, §2.4 and §2.6). The route went with the session it ended. A sign-out at the application goes through the edge's sign_out, which ends the session in its store and at Keycloak.

What would reverse it: a requirement that a sign-out at Keycloak end the session at once. The receiver then belongs at the edge, where the session is, and the checks above are what it owes.

One session reader, and the session is a prop

sessionReader on ./server is the one reader of a Session: it verifies the forwarded token and reads its claims with claims(), and kanzoAuth().session() and the forwarder are built on it. The browser has no reader: the server hands the Session to AuthProvider as a prop. It replaced two readers kept in step by a round-trip test — claims() on the server and readSession over the session route's JSON in the browser — and an exported test double, fakeAuth, standing in for the transport the browser read through. With no transport there is nothing to stand in for: a test, and the documentation's examples, pass a Session made with claims() to the provider, so what a test draws is what a token produces.

What would reverse it: a browser that has to read the session itself — a page served without a server render — which brings back a session endpoint, its reader and a double for it.

Held by packages/auth/src/server.test.ts, "reads the person a verified token names into a Session, organizations and roles inside them"; packages/auth/src/index.test.ts, "are the session reader and its claim reader on the server door".

The realm ships a baseline

services/auth/realm declares brute-force detection, a password policy, login and admin events with a retention, no offline_access among the default optional scopes, and says its second-factor stance; the module takes the figures as variables with safe defaults. Today it sets none of these (a search of realm and modules for brute-force, password policy, events, OTP and email verification found nothing), and offline_access is an optional scope on every client by default (modules/app/variables.tf), though the edge never asks for it: its scope is configuration, so a sign-in link cannot add one. Whether Keycloak's own defaults cover brute force on a new realm is not confirmed: the fetch of the admin guide returned only its table of contents. The module sets them explicitly so the posture does not depend on a default nobody read. The second-factor stance is that passkeys carry user_verification=required already and no further factor is required by the platform; a deployment that wants one declares it.

The platform realm owns the baseline; the package asks for none of it. A deployment that is not this repository's realm configures the same Keycloak settings itself, and the package assumes them only where it says so. The shape is the module's and the production values are the deployment's, which is the rule the services page already has for hostnames and passwords. The development seed's passwords must satisfy the policy. This changes the development stack the day it lands.

What would reverse it: a deployment that brokers authentication to an upstream IdP through Keycloak, where some of these live upstream; the realm's settings then stay as the floor. Using another provider in place of Keycloak is a non-goal, not a reversal of this rule.

To be added: verify.sh reads the realm's brute-force, policy and event settings back through the admin API and fails on a difference.

Renewal is the edge's, and the application scales freely

The application renews nothing, so any number of its instances can run behind the edge. Renewal and its race are oauth2-proxy's. It replaced a stated limit of one web host: renewal was single-flight per ticket within a process, and two processes could both spend one rotated refresh token, which RFC 9700 §4.14.2 says the server answers by revoking the family. The race has not gone away; it moved to the edge, which takes a lock in its store around a refresh (oauth2-proxy's session locking with a Redis store, as its documentation describes; not measured here with two edge replicas).

The cheap mitigation is still Keycloak's, at the realm, and noted and not enabled: refresh_token_max_reuse=1 lets a refresh token be presented once more after it is spent. It weakens theft detection (RFC 9700 §4.14), the reason it is a decision for the first second edge replica.

What would reverse it: a second auth-proxy replica measured spending a refresh token twice. Then the realm setting above, or sender-constrained refresh tokens (RFC 9449), is chosen against that measurement, and nothing in this package changes.

Limits the layer states instead of fixing

  • The access token outlives a sign-out, up to its lifespan. The edge's session ends at once; a token already forwarded to an API is a bearer token until it expires, five minutes on this realm. Reversed by a resource server that introspects (RFC 7662) or checks a revocation list, which turns the window into zero at that API's cost per request.
  • The forwarded token names every organization. An exchanged token named one; this one is as good at every organization's API the person belongs to (why), and the line between customers is the forwarder's organization/denied and each API reading only its own organization's entry. Reversed by an API that cannot do that, which brings the exchange back.
  • The cookie is __Host-, not __Host-Http-, and SameSite=Lax, not Strict. RFC 10017 §6.1.3.2 (read) wants the Http variant and Strict. Strict drops the cookie on the navigation back from Keycloak, and the status of the Http prefix in RFC 6265bis was not checked here. It is the edge's cookie now (OAUTH2_PROXY_COOKIE_NAME). Reversed by either being established.
  • An access token is told from an ID token by its payload's typ, not by RFC 9068's at+jwt. Keycloak signs both with the same keys for the same azp; it types the access token Bearer and the ID token ID (measured on 26.8.0, 15 October 2026), and the reader refuses anything but Bearer as token/refused. Reversed by Keycloak's at+jwt header confirmed on the realm, which the reader then requires instead.

Production-grade axes

AxisWhat this page commits to
Failure and recoveryThe forwarder ends a hung hop and the person's leaving; the realm's keys that cannot be fetched are an outage, not a refusal; renewal and its race are the edge's
Security and privacyNo inline script on the origin; fail-closed CSRF on the forwarder; a forwarded token verified on every read, so a forged header is worth nothing; every path behind the edge; a realm baseline set explicitly
Performance and sizeThe relying party left the application: the root is under 1 kB and ./next under 3.5 kB over it; per-request rendering is the nonce's cost
Stable API, accessibility, testsOne reader for Session, and the session a prop; nothing here draws, so no WCAG surface beyond the session a product draws from; every test cited above

Breaking, in the consumer's words

  • Everything the edge took is gone from the package: the routes, the stores and store, public, staticExtensions, the back-channel logout route and its checks, fakeAuth and ./testing. The list, in a consumer's words, is on the auth layer.
  • A request to the forwarder with neither Sec-Fetch-Site nor an Origin header is refused, and there is no option to allow it: only the browser calls /api/*. A curl, a probe or a test runner calls the API at its own origin with its own credential.
  • Your CSP is the csp option of kanzoAuth, applied by proxy; keep 'unsafe-inline' out of script-src, and give worker-src and connect-src explicitly.
  • A forwarded API that does not send its headers in time answers a 504 problem, api/silent.
  • A path that must open without a session is listed at the edge (OAUTH2_PROXY_SKIP_AUTH_ROUTES), not in the application.

What was read, and how far

ReferenceReadUsed for
RFC 10017, OAuth 2.0 for Browser-Based Applications (BCP 212)§6.1.2.2, §6.1.3.2, §6.1.3.3, §6.1.4.3; the threat discussion beyond the first 100 kB was notthe BFF pattern, the cookie, CSRF
RFC 9700§2.2.2, §2.3, §4.14.2refresh-token rotation and reuse, audience
RFC 9457§4.2.1about:blank and the title
OWASP CSRF Prevention Cheat Sheetthe Fetch Metadata and missing-header guidancefail-closed
OWASP ASVS 5.0V7 (7.1 to 7.6)session lifetime and termination
OpenID Connect Back-Channel Logout 1.0§2.4, §2.6the logout checks the edge would owe
CSP Level 3, Next's CSP guide, RFC 9449, RFC 6265bis, Keycloak's admin guide, oauth2-proxy's session-locking documentationnot readnamed only as references to read when the rule they bear on is next changed

On this page