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
| Credential | Lifetime | Stored |
|---|---|---|
| Access token | expires_in as reported by Zitadel (fallback 3600s) | SPA memory only, in the Zustand auth store — never localStorage, never sessionStorage |
| Refresh token | 7 days, rolling (re-set on every refresh) | httpOnly, signed cookie set by the API; the SPA cannot read it |
| ID token | 7 days | httpOnly, 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.
| Cookie | Contents | maxAge | Purpose |
|---|---|---|---|
SESSION_COOKIE_NAME (lucille_rt by default) | refresh token | 604800 (7 days) | Silent renewal via POST /api/auth/refresh |
lucille_pkce | PKCE code verifier | 600 (10 min) | Consumed and cleared at the callback |
lucille_state | OAuth state | 600 (10 min) | Compared with the callback query; mismatch is a 401 |
lucille_idt | ID token | 604800 (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'
});
| Parameter | Value |
|---|---|
| JWKS URL | AUTH_JWKS_URL if set, otherwise constructed as ${ZITADEL_ISSUER}/oauth/v2/keys |
issuer | ZITADEL_ISSUER with trailing slashes stripped |
audience | ZITADEL_CLIENT_ID — the option is omitted entirely when no client id is configured |
algorithms | Not pinned. The resolved JWKS key restricts the usable algorithm; Zitadel signs RS256 by default |
| Clock tolerance | AUTH_CLOCK_TOLERANCE, default 30s |
| Key caching | cacheMaxAge 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
| Field | Claims consulted, in order |
|---|---|
subject | sub (required — no sub is a 401) |
email | email, then preferred_username, then urn:zitadel:iam:user:email (required) |
displayName | name, 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 key | Emitted when |
|---|---|
urn:zitadel:iam:org:project:roles | The generic, non project-scoped form |
urn:zitadel:iam:org:project:{ZITADEL_PROJECT_ID}:roles | The 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.
| Layer | Setting | Where | Effect |
|---|---|---|---|
| Project | "Assert Roles on Authentication" (projectRoleAssertion: true) | Project settings — set by the Chrono zitadel template | Role information is made available for tokens and the UserInfo endpoint |
| Application | "User Roles Inside Access Token" | Application → Token Settings, console only | Roles are embedded directly in the access token |
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.
| Guard | Passes for | Rejection |
|---|---|---|
app.requireAuth | any valid token | 401 when the token is absent or unusable |
app.requireChef | chef or admin — an admin can do anything a chef can | 401 unauthenticated; 403 for any other role |
app.requireAdmin | admin only | 401 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):
| Situation | Status |
|---|---|
| Placing an order for a restaurant the chef is not assigned to | 403 |
Listing orders with an explicit restaurantId filter the chef is not assigned to | 403 |
| Fetching a single order belonging to another restaurant | 404 |
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:
| Field | On create | On update |
|---|---|---|
id | token sub | the lookup key, never rewritten |
email | from the token | overwritten from the token |
displayName | from the token, may be null | overwritten only when the token carries one, so a token without a name cannot blank an existing value |
role | resolved role | overwritten 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
- The administrator creates the user in Zitadel and grants
cheforadmin. - The user signs in once, which creates their
usersrow on the first authenticated API call. - The administrator assigns restaurants with
POST /api/admin/restaurants/:id/chefs(DELETE /api/admin/restaurants/:id/chefs/:userIdremoves 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
| Variable | Default | Purpose |
|---|---|---|
ZITADEL_ISSUER | http://localhost:8080 | Issuer; base for discovery and the constructed JWKS URL |
ZITADEL_CLIENT_ID | unset | Validated against aud; also the OIDC client_id |
ZITADEL_CLIENT_SECRET | unset | Enables client_secret_basic at the token endpoint |
ZITADEL_PROJECT_ID | unset | Project-scoped role claim key and the :aud scope |
ZITADEL_REDIRECT_URI | http://localhost:3000/api/auth/callback | Must match the application's registered redirect URI exactly |
ZITADEL_POST_LOGOUT_REDIRECT_URI | http://localhost:5173/login | Where Zitadel returns after end-session |
AUTH_JWKS_URL | derived from the issuer | Overrides the JWKS URL; used by the test harness |
AUTH_CLOCK_TOLERANCE | 30s | jwtVerify clock tolerance |
SESSION_COOKIE_NAME | lucille_rt | Refresh-token cookie name |
SESSION_COOKIE_SECRET | local development placeholder | Signs every auth cookie — must be replaced outside development |
FRONTEND_ORIGIN | http://localhost:5173 | CORS 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 throwawaynode:httpserver on a pid-derived port and signs tokens with genuine Zitadel claim shapes, including the nested role claim. The productioncreateRemoteJWKSetpath runs verbatim rather than being stubbed. - D5 / D10 — separate subdomains. The API is served from
api.<domain>and the SPA fromorders.<domain>, so the callback is a cross-origin redirect:ZITADEL_REDIRECT_URIpoints at the API host while the fragment redirect goes toFRONTEND_ORIGIN, and CORS runs withcredentials: true. - D12 — fragment delivery of the access token, as described above.
- D15 —
404rather than403for 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
httpOnlysigned cookie that JavaScript cannot read at all. - The
adminrole 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
- Roles and permissions — the route-to-role table.
- API reference — the auth endpoints in detail.
- Troubleshooting — what missing role claims look like in practice.