Skip to content
sign in

==> read the decisions

ADR-0024: Zitadel is the identity provider; its sign-in and account pages are the platform's own, at the identity host

Status: Accepted (2026-10-15). Supersedes ADR-0019 (Authentik). Amends ADR-0005 and ADR-0016 (the platform keeps no sessions: they are Zitadel's, shown at the identity host; invitations and resets become enrolment there) and ADR-0022 (the docs may take their try-it token from the identity provider). Implemented 2026-10-15 against Zitadel v4.19.5; what the spike measured changed four points below (marked measured). Amended by ADR-0025: the profile opens the account pages in the same tab; every sign-out lands on the app's front door.

ADR-0019 put sign-in behind Authentik, with the app as its OIDC client. Running it showed the cost of someone else's sign-in pages: their framework and look, a language picker but no place for the platform's bar, themes or language, customisable only by CSS; and a heavy service whose own defaults kept undoing its configuration.

What the platform wants from an identity system is unchanged: passkeys, a password with TOTP as the fallback, sign-in with Google and GitHub later, an account dashboard for a user's devices, factors and sessions, an administration interface, standard OIDC for the applications that sign users in, and credential code maintained by people whose job it is. What it does not want is anyone else's pages.

  • Zitadel is the identity provider -- users, credentials (passkeys, passwords, TOTP, linked external accounts), identity sessions, and the OIDC endpoints -- on the platform's Postgres. It was chosen over Authentik because its public, versioned APIs (Session API v2, User API v2, the OIDC auth-request API) are designed for a login someone else writes. Zitadel's own sign-in pages serve only its console, off the public host (below).
  • The identity host, id.<domain>, is a surface of the web project, like the homepage, the app and the docs: the sign-in, enrolment and recovery pages and the account dashboard, with the bar, the themes and the language like every page. Zitadel's OIDC endpoints (/oauth/v2/*, /oidc/v1/*, /.well-known/*, its keys) answer on the same host; the edge routes those paths to Zitadel and every other path to the web server.
  • The pages drive Zitadel through the API. The identity host's server calls the platform's API, which holds a Zitadel service account with the login-client role (on the hub, never at the edge) and makes the Session, User and auth-request calls: a session for the username, a passkey challenge or a password then a passkey or TOTP check, and, for an OIDC sign-in, completing the auth request with the verified session.
  • Every application is a standard OIDC client, the app first among them (code flow with PKCE; the client written for ADR-0019 stands). Zitadel sends an authorization request to the identity host's sign-in page; the page completes it.
  • Every session is Zitadel's; the platform keeps none. Signed in, the app holds Zitadel's tokens -- an access token that is a short-lived JWT (five minutes) and a refresh token -- in one host-only, httpOnly cookie, encrypted under the API's key, and the API verifies the JWT's signature on each request, with no lookup. Zitadel lists every client of the project in the token's audience, so the API checks its client_id: the app's token is refused on the docs, and the reverse. When it expires, the API refreshes it against Zitadel on the hub and sets the new cookie -- after asking Zitadel whether the session still exists: measured, ending a Zitadel session does NOT revoke its refresh tokens. Zitadel's back-channel logout names an ended session, and the API refuses its tokens from then on (held in memory, only for as long as such a token could live); a cookie that outlives that memory dies at its next refresh, on that question. One refresh is in flight per refresh token at a time -- concurrent requests wait for it, and a request that still carries the old token a moment later gets the same answer -- because a rotated token used twice is refused (measured). The control database's sessions, and the profile's list of them, are removed: a user's devices and sessions are Zitadel's, shown at the identity host. CSRF tokens bind to the Zitadel session; personal access tokens are unchanged.
  • Single sign-on lives at the identity host. Its own host-only cookie holds the Zitadel session it signed in with, so a second application's authorization request completes without a screen.
  • Sign-out is everywhere. An application's sign-out ends the Zitadel session through the API; Zitadel's back-channel logout (on by default, enabled per client by its backchannel_logout_uri, the logout token naming the session) then ends every application's session from it, and each host's own cookies die at their next request. The apex's signed-in hint is cleared by whoever signs out.
  • Passkeys are bound to the apex domain (the WebAuthn relying-party ID is <domain>), so they are valid on every host of the platform and survive pages moving between hosts.
  • Passkeys are username-first. Zitadel issues a passkey challenge only for a known user and does not register discoverable credentials (its issue #8899, tracked in the fleet backlog), so there is no sign-in by passkey alone and no autofill; the pages remember the last username on the device, so a returning user taps once. A newly registered passkey does not yet count as the session's check (issue #12885): enrolment prompts twice, once.
  • The account dashboard is at the identity host: passkeys (list, add, remove), TOTP, password and sessions, through the User and Session APIs; changing a way in asks for a check within the last ten minutes, and the last way in cannot be removed. The app's profile links to it, in a new tab. Users never see Zitadel's console: it is on the hub's loopback, and its administrator signs in with Zitadel's own login there (nixos2 ADR-0020) -- measured, Zitadel sends every client, console included, to the platform's pages when that is the instance's default, so the platform's clients choose the platform's pages each, and the instance keeps its own for the console.
  • Users come from the roster. The reconciler creates a Zitadel user for each roster user and deactivates the ones that leave, as it does in the control database -- best effort: without Zitadel the API still starts. Enrolment is a one-time link the operator prints (cs-admin invite), to the identity host's enrolment page; its 256-bit code's hash and expiry are kept on the user in Zitadel and deleted when it is used (measured: Zitadel's own invite codes are six characters, and refused for a user who already has a credential). The same link recovers a user who lost theirs, and then signs out every session they had.
  • A fresh sign-in (before a new personal access token, ADR-0020) is a new authorization request with prompt=login and max_age=0, its auth_time checked, as built for ADR-0019; the new tokens' auth_time is what the API checks.
  • The docs host is a confidential OIDC client of its own, like the app: the API is its backend (docs.<domain>/api/*, a door of its own), its tokens are in host-only httpOnly cookies, and it signs in only for the reader's own projects' documentation (ADR-0022). MCP clients still need Client ID Metadata Documents, which Zitadel has not shipped, so the API stays their authorization server (ADR-0020).
  • Languages: the pages are rendered in the reader's language (the cs-prefs cookie, the authorization request's ui_locales), as every surface is.
  • The sign-in walk (api/test/walk.ts: a browser with a virtual authenticator, against the real Zitadel under dev.sh) is the gate for every upgrade of the pinned version: enrolment, sign-in with a passkey and with a password and a code, single sign-on to the app and the docs, a fresh sign-in before a token, a refresh after expiry under concurrent requests, recovery, sign-out everywhere.

  • The platform holds no session state; a request is authenticated by a signature.

  • With Zitadel down, signed-in users keep working until their access token expires (minutes), then cannot refresh or sign in until it is back. Accepted. An ended session's last access token stays usable only if the API restarted after Zitadel's logout notice, and then for minutes at most.

  • One sign-in experience, in the platform's look and language; the standard protocol between the identity host and every application, so a new client -- a docs host, a fleet service, a third party -- needs no trust code of its own.

  • The API holds a credential that can complete a sign-in for any user. Whoever controls the API already controls every account through the control database; it stays on the hub, and the edge and the web server never hold it.

  • The platform writes the identity pages and keeps up with Zitadel's v2 APIs (public and versioned).

  • Changing the identity provider later touches the identity host's pages and the API's calls to it; the applications, being OIDC clients, do not change. Passkeys would be enrolled again, as with any change of provider.

  • The app's own sessions in the control database (an opaque cookie, checked on each request): instant revocation, but a second store of sessions beside Zitadel's and a list of them to keep.
  • No OIDC, sign-in at the app's host (an earlier draft of this ADR): one hop fewer, but every other consumer -- the docs, a fleet service, a third party -- would need its own arrangement with the API, and the account dashboard would live inside the app.
  • Authentik, kept and themed. CSS reaches most of its look but not its structure; the platform's bar, themes and language controls cannot run in it.
  • Authentik headless, driving its flow executor: that API is its own UI's internal contract.
  • Zitadel's login application, forked. A Next.js application beside an Astro platform: a second web stack to run and keep merged.
  • Pocket ID. Light and ahead on MCP, but passkeys only and no sign-in with Google or GitHub.
  • Janssen, Keycloak. Far heavier than a personal cloud needs.
  • All of it in the API. The platform would own WebAuthn, attestation, TOTP and external sign-in code for the life of the project.