Skip to main content

Authentication and authorisation

Identity is delegated entirely to Zitadel, provisioned from the Chrono zitadel service template (infra/dev/lucille-zitadel.yaml, infra/prod/lucille-zitadel.yaml). One organisation (lucille-org), one project (lucille-order-center), one OIDC application (lucille-web-app, appType: WEB, authMethodType: BASIC), two project roles: chef and admin. There is no local password store, no self-registration and no user-creation screen in the Order Center.

The whole OIDC handshake is driven by the API, not by an OIDC library in the browser. The SPA never holds a client secret, never sees a refresh token and never talks to Zitadel's token endpoint. It asks the API for an authorization URL, and later receives a short-lived access token.

The token flow

SPA API Zitadel
│ │ │
│ 1. no session │ │
│─ GET /api/auth/login ───►│ generate state + PKCE │
│ Accept: application/json verifier/challenge (S256) │
│◄─ 200 { authorizationUrl, state } ─ + 2 signed cookies │
│ │ │
│ 2. window.location.assign(authorizationUrl) ─────────►│
│ scope=openid profile email offline_access │
│ urn:zitadel:iam:org:project:roles │
│ urn:zitadel:iam:org:project:id:{pid}:aud │
│ │
│ 3. user authenticates │
│◄──── 302 {ZITADEL_REDIRECT_URI}?code=…&state=… ───────│
│ │ │
│─ GET /api/auth/callback ►│ verify state + read verifier│
│ │ 4. POST /oauth/v2/token ──►│
│ │ code + code_verifier │
│ │◄── access + id + refresh ──│
│ │ 5. Set-Cookie lucille_rt, lucille_idt
│◄─ 302 {FRONTEND_ORIGIN}/auth/callback#access_token=…&expires_in=…&token_type=Bearer
│ │
│ 6. Authorization: Bearer <access token> on every API call
│ → the API upserts the users row from the claims on EVERY request
│ 7. POST /api/auth/refresh (cookie only) → { accessToken, expiresIn, tokenType }

GET /api/auth/login content-negotiates: it answers { authorizationUrl, state } when the caller sends Accept: application/json (what the SPA's login page does) and issues a 302 otherwise, so a bare browser hit on the endpoint still works.

Note step 6. The users row is not written at the callback — it is upserted inside the request context builder, which runs on every authenticated request.

What is stored where

CredentialLifetimeStored
Access tokenexpires_in as reported by Zitadel (fallback 3600s)SPA memory only, in the Zustand auth store — never localStorage, never sessionStorage
Refresh token7 days, rolling (re-set on every refresh)httpOnly, signed cookie set by the API; the SPA cannot read it
ID token7 dayshttpOnly, signed cookie; replayed as id_token_hint at logout

All four auth cookies are set through one helper and share the same flags: httpOnly: true, sameSite: 'strict', path: '/', signed: true, and secure only when NODE_ENV=production (so local development over plain HTTP works). Signing uses SESSION_COOKIE_SECRET via @fastify/cookie registered with hook: 'onRequest'; every read goes through reply.unsignCookie and a cookie that fails the signature check is treated as absent.

CookieContentsmaxAgePurpose
SESSION_COOKIE_NAME (lucille_rt by default)refresh token604800 (7 days)Silent renewal via POST /api/auth/refresh
lucille_pkcePKCE code verifier600 (10 min)Consumed and cleared at the callback
lucille_stateOAuth state600 (10 min)Compared with the callback query; mismatch is a 401
lucille_idtID token604800 (7 days)id_token_hint for the Zitadel end-session call

lucille_pkce and lucille_state are cleared as soon as the code exchange succeeds. Zitadel rotates refresh tokens, so POST /api/auth/refresh re-sets lucille_rt with the replacement — without that, the next renewal would fail.

Handing the access token to the SPA (fragment, not query)

The callback redirects the browser to {FRONTEND_ORIGIN}/auth/callback with a URL fragment carrying exactly three parameters: access_token, expires_in and token_type. FRONTEND_ORIGIN may be a comma-separated list (it also feeds CORS); the redirect uses the first entry.

A fragment is chosen deliberately (decision D12): a fragment is never sent to a server, so the token cannot land in an ingress access log, in a Referer header or in any proxy in between — which a query string cannot promise. The callback page reads window.location.hash and never window.location.search, wipes the fragment with history.replaceState before any state update can trigger a render, then puts the token in the in-memory store and schedules a proactive refresh. A hard reload therefore starts with no token and recovers the session with a single silent POST /api/auth/refresh; an isBootstrapped flag stops route guards bouncing the user to /login while that round trip is in flight. The page also honours an error fragment parameter and shows a "could not complete sign-in" card.

Verifying the access token

Verification is local, against Zitadel's JWKS. The introspection endpoint is never called per request.

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Lazily created once per process, then memoised.
const jwks = createRemoteJWKSet(new URL(auth.jwksUrl), {
cooldownDuration: 30_000,
cacheMaxAge: 10 * 60_000,
timeoutDuration: 5_000,
});

const { payload } = await jwtVerify(token, jwks, {
issuer: auth.issuer, // ZITADEL_ISSUER, trailing slashes trimmed
...(auth.clientId ? { audience: auth.clientId } : {}),
clockTolerance: auth.clockTolerance, // AUTH_CLOCK_TOLERANCE, default '30s'
});
ParameterValue
JWKS URLAUTH_JWKS_URL if set, otherwise constructed as ${ZITADEL_ISSUER}/oauth/v2/keys
issuerZITADEL_ISSUER with trailing slashes stripped
audienceZITADEL_CLIENT_ID — the option is omitted entirely when no client id is configured
algorithmsNot pinned. The resolved JWKS key restricts the usable algorithm; Zitadel signs RS256 by default
Clock toleranceAUTH_CLOCK_TOLERANCE, default 30s
Key cachingcacheMaxAge 10 minutes, cooldownDuration 30s between refetches, timeoutDuration 5s

Two things about that table are easy to get wrong. First, the JWKS URL is constructed, not discovered: OIDC discovery is fetched (and cached for 10 minutes) for the authorization_endpoint, token_endpoint and end_session_endpoint, but its jwks_uri is deliberately ignored so that AUTH_JWKS_URL can redirect verification at a local key server. Second, audience validation is conditional — a deployment that boots without ZITADEL_CLIENT_ID verifies signature and issuer but performs no audience check. The config schema is lenient on purpose (a half-configured environment must still serve /healthz), so this is a real deployment risk to check rather than a documented feature.

createRemoteJWKSet is chosen over a static key fetched at boot because Zitadel rotates signing keys without prior notice; the set is re-fetched on a kid miss, subject to the cooldown, so a rotation heals itself. Keys are also resolved lazily on the first request rather than pre-fetched, because a low-traffic instance can legitimately serve an empty key set between rotations and failing hard at boot on that would be a self-inflicted outage.

Every failure mode — malformed token, bad signature, expired, wrong issuer, wrong audience, missing sub, missing email, no usable role — raises the same UnauthenticatedError, i.e. 401 with error code UNAUTHENTICATED.

Identity claims read from the token

FieldClaims consulted, in order
subjectsub (required — no sub is a 401)
emailemail, then preferred_username, then urn:zitadel:iam:user:email (required)
displayNamename, else given_name + family_name, else null

Both role claims are read and merged

The role claim is not a flat array of role names. It is a nested object whose top-level keys are role names, each mapping to an orgId → orgDomain object:

{
"urn:zitadel:iam:org:project:roles": {
"chef": { "201982826478953724": "lucille.localhost" }
}
}

There are two claim keys, and the API reads both and unions them — this is a merge, not a fallback:

Claim keyEmitted when
urn:zitadel:iam:org:project:rolesThe generic, non project-scoped form
urn:zitadel:iam:org:project:{ZITADEL_PROJECT_ID}:rolesThe token is audience-scoped to a specific project
export function rolesFromPayload(payload: JWTPayload, projectId?: string): string[] {
const generic = extractRoles(payload[ZITADEL_ROLES_CLAIM]);
const scoped = projectId ? extractRoles(payload[projectRolesClaim(projectId)]) : [];
return [...new Set([...generic, ...scoped])];
}

extractRoles is tolerant by design: a missing, null, array or otherwise malformed claim yields [] instead of throwing, and role names come from Object.keys rather than array iteration. The project-scoped claim is only consulted when ZITADEL_PROJECT_ID is configured.

Precedence applies afterwards, when the merged role names are collapsed to one effective role: admin wins over chef, and unknown names (accountant, say) are ignored. A token whose merged set contains neither chef nor admin is rejected with 401 — not 403 — accompanied by a deliberately loud warn log naming the two Zitadel toggles, because in practice that state means a configuration fault rather than a user who lacks access. Both cases are asserted in apps/api/tests/auth.test.ts.

The two Zitadel toggles

Role assertion is a two-layer setting. Both layers must be on or roles are silently absent from tokens.

LayerSettingWhereEffect
Project"Assert Roles on Authentication" (projectRoleAssertion: true)Project settings — set by the Chrono zitadel templateRole information is made available for tokens and the UserInfo endpoint
Application"User Roles Inside Access Token"Application → Token Settings, console onlyRoles are embedded directly in the access token
Manual step after provisioning

The application-level toggle cannot be set through the ZitadelApplication custom resource and must be enabled by hand in the Zitadel console for lucille-web-app, in every environment. The requirement is recorded as a banner comment at the top of infra/dev/lucille-zitadel.yaml. Symptom when it is missed: authentication succeeds, the token verifies, and every request fails with 401 plus the "carries no Lucille Order Center role" message.

Scopes, and who requests them

The scope string is built server-side by the API when it constructs the authorization URL, so the frontend cannot get it wrong:

openid profile email offline_access urn:zitadel:iam:org:project:roles
urn:zitadel:iam:org:project:id:{ZITADEL_PROJECT_ID}:aud

The audience scope is appended only when ZITADEL_PROJECT_ID is set. Without it, tokens verify cryptographically but the API's project is absent from aud, so strict audience validation rejects every call. The same scope string is re-sent on the refresh_token grant. Client authentication at the token endpoint is client_secret_basic when a client id and secret are configured; with an id only, the client id goes in the form body and PKCE alone protects the exchange.

The three guards

Guards are attached per route as Fastify preHandler hooks, never globally, so an unguarded route is visibly unguarded in its definition — and apps/api/tests/rbac.test.ts asserts that every registered route except the public ones carries a guard.

GuardPasses forRejection
app.requireAuthany valid token401 when the token is absent or unusable
app.requireChefchef or admin — an admin can do anything a chef can401 unauthenticated; 403 for any other role
app.requireAdminadmin only401 unauthenticated; 403 for a chef

The status-code rule is exact: 401 for an absent or invalid token, 403 for a valid token whose role or restaurant scope does not permit the request. Because the effective role can only ever be chef or admin, requireChef's 403 branch is unreachable today — it is kept as a defensive guard against a future third role. The SPA mirrors the same rule in roleSatisfies, where admin satisfies every requirement.

Restaurant scope produces 403 or 404 depending on the question asked (decision D15):

SituationStatus
Placing an order for a restaurant the chef is not assigned to403
Listing orders with an explicit restaurantId filter the chef is not assigned to403
Fetching a single order belonging to another restaurant404

The asymmetry is intentional: a filter the caller may not use is a mistake worth naming, whereas answering 403 for a specific order id would confirm that the order exists, letting a chef probe other locations.

Restaurant ids come from the database

Restaurant scoping does not come from the token. The nested value inside each role entry is an orgId → orgDomain pair — organisation metadata, not a restaurant identifier — so after the JWT verifies the context builder reads the assignments from app.user_restaurants (Prisma model UserRestaurant), keyed on the token sub. The schema comment on that model says so explicitly: this table, not the role claim, is the source of truth for query scoping.

Three consequences, all desirable: unassigning a chef takes effect on their next request rather than when their access token expires; assignments are relational data with foreign keys and ON DELETE CASCADE; and the token stays small, so adding a restaurant needs no Zitadel Actions to reshape claims.

The request context

interface RequestContext {
userId: string; // Zitadel `sub`
email: string;
role: 'chef' | 'admin';
restaurantIds: string[]; // from app.user_restaurants, populated for admins too
}

restaurantIds is populated from the join table for every role, including admin. Callers must not interpret an empty list as "no access" — that decision is made by restaurantScope(context), which returns null for an admin, meaning "apply no restaurant filter at all", and the assigned ids for a chef. canAccessRestaurant(context, id) is the matching single-restaurant check and always returns true for an admin. Once a guard has run, handlers reach the context through requestContext(request), which throws rather than returning undefined if a route forgot its guard. The request logger is also enriched with userId and role, and the rate limiter keys on ctx.userId when one is present.

Row-level security is deliberately not used. At this scale application-level scoping backed by an exhaustive RBAC matrix (see testing) is proportionate and keeps a single connection pool.

The user row is re-synced on every request

buildRequestContext performs a Prisma upsert on users for every authenticated request:

FieldOn createOn update
idtoken subthe lookup key, never rewritten
emailfrom the tokenoverwritten from the token
displayNamefrom the token, may be nulloverwritten only when the token carries one, so a token without a name cannot blank an existing value
roleresolved roleoverwritten from the token's merged role claims

Because the role is re-written from the claims on every call, revoking admin in Zitadel takes effect on the user's next request. There is no local role cache to wait out and no manual sync step; the only window is the remaining lifetime of an already-issued access token. apps/api/tests/auth.test.ts asserts both halves: a brand-new sub gets a row created on its first call, and a sub whose token changes from chef to admin has the mirrored role changed with it.

Provisioning a user

  1. The administrator creates the user in Zitadel and grants chef or admin.
  2. The user signs in once, which creates their users row on the first authenticated API call.
  3. The administrator assigns restaurants with POST /api/admin/restaurants/:id/chefs (DELETE /api/admin/restaurants/:id/chefs/:userId removes one).

Step 2 must precede step 3, so a brand-new chef signs in once and sees an empty restaurant picker until the assignment exists. GET /api/auth/me returns the caller's id, email, display name, role and restaurants — the assigned active restaurants for a chef, every active restaurant for an admin.

Logout

POST /api/auth/logout clears all four cookies and returns { loggedOut: true, logoutUrl }. logoutUrl is Zitadel's end_session_endpoint with post_logout_redirect_uri, client_id and, when the lucille_idt cookie is present and valid, id_token_hint. It is null when the issuer advertises no end-session endpoint or when building the URL fails — the local session is dropped either way, and the failure is logged as a warning rather than propagated, so a Zitadel outage cannot trap a signed-in user.

Environment variables

VariableDefaultPurpose
ZITADEL_ISSUERhttp://localhost:8080Issuer; base for discovery and the constructed JWKS URL
ZITADEL_CLIENT_IDunsetValidated against aud; also the OIDC client_id
ZITADEL_CLIENT_SECRETunsetEnables client_secret_basic at the token endpoint
ZITADEL_PROJECT_IDunsetProject-scoped role claim key and the :aud scope
ZITADEL_REDIRECT_URIhttp://localhost:3000/api/auth/callbackMust match the application's registered redirect URI exactly
ZITADEL_POST_LOGOUT_REDIRECT_URIhttp://localhost:5173/loginWhere Zitadel returns after end-session
AUTH_JWKS_URLderived from the issuerOverrides the JWKS URL; used by the test harness
AUTH_CLOCK_TOLERANCE30sjwtVerify clock tolerance
SESSION_COOKIE_NAMElucille_rtRefresh-token cookie name
SESSION_COOKIE_SECRETlocal development placeholderSigns every auth cookie — must be replaced outside development
FRONTEND_ORIGINhttp://localhost:5173CORS allow-list and the callback redirect target (first entry)

In the cluster, ZITADEL_CLIENT_ID, ZITADEL_CLIENT_SECRET and ZITADEL_ISSUER arrive from the Vault secret written by the zitadel service definition (secretKeyMapping); the remaining keys live in the manually-maintained lucille-api secret.

Where the code deviates from the plan

  • D2 — Zitadel cannot be run in the build pod. Hence AUTH_JWKS_URL. The integration suite generates a real RS256 keypair, publishes the public half from a throwaway node:http server on a pid-derived port and signs tokens with genuine Zitadel claim shapes, including the nested role claim. The production createRemoteJWKSet path runs verbatim rather than being stubbed.
  • D5 / D10 — separate subdomains. The API is served from api.<domain> and the SPA from orders.<domain>, so the callback is a cross-origin redirect: ZITADEL_REDIRECT_URI points at the API host while the fragment redirect goes to FRONTEND_ORIGIN, and CORS runs with credentials: true.
  • D12 — fragment delivery of the access token, as described above.
  • D15 — 404 rather than 403 for another restaurant's order, to avoid an existence oracle.

Threat model

  • No public registration; every user is provisioned by the administrator in Zitadel.
  • A chef cannot reach another restaurant's data. Enforced at the API and tested per endpoint.
  • Access tokens never touch web storage, so an XSS foothold cannot exfiltrate a durable credential; the refresh token is in an httpOnly signed cookie that JavaScript cannot read at all.
  • The admin role is effectively a single account, so compromise of its credentials is the primary risk. Enabling Zitadel MFA through organisation policy is the intended mitigation, but it is not expressed in the Zitadel custom resources in this repository — treat it as a console-side operational step, not as something the code enforces.
  • All external traffic is HTTPS, terminated at the Kong ingress. Prisma's parameterised queries remove SQL injection as a class. Security headers, including Content-Security-Policy, are an ingress concern described in deployment rather than something the API sets.

Verification status

This page was verified line by line against the committed source on 2026-08-13: apps/api/src/auth/jwt.ts, apps/api/src/auth/oidc.ts, apps/api/src/auth/context.ts, apps/api/src/plugins/auth.ts, apps/api/src/routes/auth.ts, apps/api/src/routes/admin/restaurants.ts, apps/api/src/config/index.ts, apps/api/src/errors.ts, apps/api/src/app.ts, apps/api/prisma/schema.prisma, packages/types/src/auth.ts, packages/types/src/user.ts, apps/api/tests/auth.test.ts, apps/api/tests/rbac.test.ts, apps/api/tests/helpers/jwks.ts, apps/frontend/src/pages/login.tsx, apps/frontend/src/pages/auth-callback.tsx, apps/frontend/src/stores/auth-store.ts, apps/frontend/src/hooks/use-auth-bootstrap.ts and infra/dev/lucille-zitadel.yaml. Two statements above are marked as unverified against code, because they are configured outside this repository: Zitadel MFA organisation policy and the ingress Content-Security-Policy.

Where to go next