Skip to content

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 redirect

ZITADEL 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/42

Optional 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):

  1. Load the active platform by platformKey
  2. Resolve onboarding mode (query or platform default)
  3. Reject central when central_onboarding_enabled is false (409 CENTRAL_ONBOARDING_DISABLED)
  4. Require a configured callback_url (409 PLATFORM_CALLBACK_NOT_CONFIGURED)
  5. Allowlist returnTo against allowed_return_urls (absolute URL or root-relative path resolved against the first allowed root)
  6. Persist transaction (~10 minutes TTL): hashed state, hashed nonce, AES-GCM encrypted PKCE verifier and nonce ciphertext, mode, callback, normalized returnTo
  7. 302 to ZITADEL /oauth/v2/authorize with client_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:

  1. Atomically consume the transaction by hashed state (invalid/expired/already used → 404)
  2. Exchange the ZITADEL code with the stored PKCE verifier at the fixed central redirect URI
  3. Verify ID token (issuer, audience = central OIDC client, RS256, sub, nonce)
  4. Verify access token; require matching sub (401 TOKEN_SUBJECT_MISMATCH otherwise)
  5. JIT-provision local user; attach user to the transaction
  6. Bootstrap membership + onboarding evaluation for the platform
  7. Create gateway session; copy only these scopes into session permissions when present on the access token: platforms:admin, onboarding:schema:read, onboarding:schema:write
  8. Set-Cookie HttpOnly gateway session (Path=/, SameSite=Lax, Secure in production)
  9. Redirect:
    • central + status ≠ COMPLETE: ${ACCOUNT_CENTER_URL}/onboarding/{platformKey}?resume={transactionId} (requires canCompleteCentrally)
    • 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 user is { id, sub } only. OpenAPI may advertise an optional picture on the user object; the exchange handler does not currently return picture.
  • permissions are the scopes captured on the gateway session at callback time (may be empty).
  • returnTo is the server-validated absolute URL captured at start.
  • Store gatewayToken only 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:

FieldRole
platform_keyStart query identity (^[A-Z][A-Z0-9_]{1,63}$)
callback_urlBrowser handoff destination
allowed_return_urlsAllowlisted returnTo roots
default_onboarding_modenone | platform | central
central_onboarding_enabledRequired true when mode is central
client_id / secret hashBasic 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.

Next