Appearance
Authentication flow
This page mirrors the runtime behavior of src/modules/auth-flow/routes.ts and the supporting transaction, handoff, and gateway-session services. Platform teams implement only the start redirect, callback, and exchange steps.
Responsibility boundary
text
ZITADEL credentials, login/registration, MFA/passkeys, OIDC tokens
IESF Central Gateway OIDC client, transaction, callback, JIT user, onboarding, handoff
Platform BFF handoff exchange, local platform session, final returnTo redirectZITADEL has one Central Gateway OIDC application. Its callback is always /v1/auth/callback (AUTH_CALLBACK_URL). Platforms are not ZITADEL applications.
Happy path (none or platform onboarding)
Central onboarding path
When default_onboarding_mode is central and bootstrap status is not COMPLETE, the gateway does not issue a handoff yet.
resume never trusts a client completed=true flag. Only server-calculated COMPLETE continues.
1. Start login
http
GET /v1/auth/start?platformKey=IESF_COMPETITIONS&returnTo=/matches/42Optional query: onboardingMode=none|platform|central. When present it is an assertion only and must equal the platform’s registered default_onboarding_mode. It cannot override policy.
Gateway behavior (AuthenticationTransactionService.create):
- Load the active platform by
platformKey - Resolve onboarding mode (query or platform default)
- Reject
centralwhencentral_onboarding_enabledis false (409 CENTRAL_ONBOARDING_DISABLED) - Require a configured
callback_url(409 PLATFORM_CALLBACK_NOT_CONFIGURED) - Allowlist
returnToagainstallowed_return_urls(absolute URL or root-relative path resolved against the first allowed root) - Persist transaction (~10 minutes TTL): hashed
state, hashednonce, AES-GCM encrypted PKCE verifier and nonce ciphertext, mode, callback, normalizedreturnTo 302to ZITADEL/oauth/v2/authorizewithclient_id,redirect_uri=AUTH_CALLBACK_URL,response_type=code, configured scopes,state,nonce,code_challenge(S256),code_challenge_method=S256
Login and registration use the same start path.
returnTo rules
- Absolute
http:/https:URL, or a root-relative path (/…) - No userinfo, no hash
- Origin must match an allowed return URL root; pathname must equal
/or start with that root’s pathname - Forbidden roots →
403
2. Central callback
http
GET /v1/auth/callback?code=...&state=...Gateway behavior:
- Atomically consume the transaction by hashed
state(invalid/expired/already used →404) - Exchange the ZITADEL code with the stored PKCE verifier at the fixed central redirect URI
- Verify ID token (issuer, audience = central OIDC client, RS256,
sub,nonce) - Verify access token; require matching
sub(401 TOKEN_SUBJECT_MISMATCHotherwise) - JIT-provision local user; attach user to the transaction
- Bootstrap membership + onboarding evaluation for the platform
- Create gateway session; copy only these scopes into session permissions when present on the access token:
platforms:admin,onboarding:schema:read,onboarding:schema:write Set-CookieHttpOnly gateway session (Path=/,SameSite=Lax,Securein production)- Redirect:
central+ status ≠COMPLETE:${ACCOUNT_CENTER_URL}/onboarding/{platformKey}?resume={transactionId}(requirescanCompleteCentrally)- otherwise: registered
callback_url?code=handoff_…
3. Resume (central mode only)
http
GET /v1/auth/resume?transaction={uuid}Requires the gateway session cookie. The transaction must already be consumed, bound to the same local user, and belong to an active platform. Onboarding is re-bootstrapped. Incomplete central onboarding redirects back to Account Center; complete issues a handoff.
At most one handoff row exists per transaction (authentication_handoff_codes.transaction_id unique). A second issue attempt maps to 409 CONFLICT via PostgreSQL unique violation handling.
4. Platform handoff exchange
Gateway redirects to the server-registered callback:
text
https://competition.example/api/auth/callback?code=handoff_…Handoff properties (HandoffService + config):
- Prefix
handoff_ - Stored only as SHA-256
- TTL =
AUTH_HANDOFF_TTL_SECONDS(default 60, allowed range 30–300) - Bound to one platform, transaction, user, and gateway session
- Gateway session token encrypted at rest while pending
- Atomically single-use
Your BFF exchanges it with Central credentials, not ZITADEL:
http
POST /v1/auth/exchange
Authorization: Basic base64(platformClientId:platformClientSecret)
Content-Type: application/json
{ "code": "handoff_…" }Exchange response (runtime)
Exact object returned by HandoffService.exchange today:
json
{
"session": {
"id": "<gateway-session-uuid>",
"expiresAt": "<ISO-8601>",
"gatewayToken": "<opaque-token>"
},
"user": {
"id": "<local-user-uuid>",
"sub": "<zitadel-subject>"
},
"platform": {
"key": "IESF_COMPETITIONS"
},
"onboarding": {
"status": "NOT_STARTED | IN_PROGRESS | COMPLETE",
"requirementVersion": 4,
"missingFields": ["…"],
"canCompleteCentrally": true
},
"permissions": ["platforms:admin", "onboarding:schema:read", "onboarding:schema:write"],
"returnTo": "https://competition.example/matches/42"
}Notes:
- No ZITADEL tokens are returned.
- Runtime
useris{ id, sub }only. OpenAPI may advertise an optionalpictureon the user object; the exchange handler does not currently returnpicture. permissionsare the scopes captured on the gateway session at callback time (may be empty).returnTois the server-validated absolute URL captured at start.- Store
gatewayTokenonly in a protected server-side / HttpOnly session. Forward it to Central as the configured gateway cookie. Never expose it to browser JavaScript.
Invalid / expired / already used / wrong-platform codes → 404. Bad Basic credentials → 401.
Platform registry fields that drive this flow
Each active platform must have:
| Field | Role |
|---|---|
platform_key | Start query identity (^[A-Z][A-Z0-9_]{1,63}$) |
callback_url | Browser handoff destination |
allowed_return_urls | Allowlisted returnTo roots |
default_onboarding_mode | none | platform | central |
central_onboarding_enabled | Required true when mode is central |
client_id / secret hash | Basic auth for /v1/auth/exchange |
Secrets are returned only on create/rotate and stored as scrypt hashes. Callback and return URLs are never taken from untrusted callback query parameters.