Authentication Flow

Authentication Flow

Overview

The platform uses Zitadel (self-hosted OIDC provider) for identity management. Zitadel handles user credentials, MFA, and session management. The app never stores passwords — it only stores tokens issued by Zitadel.

Authentication uses the Authorization Code Flow with PKCE, which is the recommended flow for native and single-page apps.

Key Components

ComponentRole
ZitadelIdentity provider (OIDC). Manages credentials, sessions, MFA
Frontend (React Native / Expo)Initiates OIDC flow, stores tokens, sends them on API requests
Backend (Symfony)Validates JWT tokens, provisions/resolves app users
X-Organization-Id headerContext header — tells the backend which org the request targets

Login Flow

1. User opens the app

Frontend                        Backend                         Zitadel
   │                               │                               │
   ├─ Extract slug from subdomain  │                               │
   │  (e.g. "grace-chapel")       │                               │
   │                               │                               │
   ├─ GET /api/v1/organizations/  ─┤                               │
   │      resolve/{slug}           │                               │
   │                               ├─ Look up org by slug          │
   │  ◄── { organizationId,       │   (or root org if no slug)    │
   │        tenantId, name,        │                               │
   │        profileImagePath }     │                               │
   │                               │                               │
   ├─ Display org branding         │                               │
   │  (logo, name, welcome text)   │                               │

2. User clicks “Sign In”

Frontend                        Zitadel                         Backend
   │                               │                               │
   ├─ Generate PKCE code_verifier  │                               │
   │  + code_challenge             │                               │
   │                               │                               │
   ├─ Redirect (web) or popup ────►│                               │
   │  (mobile) to /oauth/v2/       │                               │
   │  authorize?                   │                               │
   │    client_id=...              │                               │
   │    redirect_uri=...           │                               │
   │    scope=openid profile       │                               │
   │      email offline_access     │                               │
   │    response_type=code         │                               │
   │    code_challenge=...         │                               │
   │    code_challenge_method=S256 │                               │
   │                               │                               │
   │                               ├─ Show login form              │
   │                               │  (or skip if session exists)  │
   │                               │                               │
   │                               ├─ Validate credentials         │
   │                               │                               │
   │  ◄── Redirect back with       │                               │
   │      ?code=AUTH_CODE           │                               │

Platform-specific behavior:

  • Web: Redirect in same tab (windowFeatures: { createTask: false })
  • Mobile (iOS/Android): In-app browser popup

3. Token exchange

Frontend                        Zitadel                         Backend
   │                               │                               │
   ├─ POST /oauth/v2/token ───────►│                               │
   │    grant_type=authorization_   │                               │
   │      code                     │                               │
   │    code=AUTH_CODE              │                               │
   │    code_verifier=...          │                               │
   │    redirect_uri=...           │                               │
   │                               │                               │
   │  ◄── { access_token (JWT),    │                               │
   │        refresh_token,          │                               │
   │        id_token }              │                               │
   │                               │                               │
   ├─ Store tokens locally          │                               │
   │  (SecureStore on mobile,      │                               │
   │   memory on web)              │                               │
   │                               │                               │
   ├─ Decode id_token → extract    │                               │
   │  user info (sub, email, name) │                               │

4. First API call (auto-provisioning)

Frontend                        Backend                         Zitadel
   │                               │                               │
   ├─ GET /api/v1/me ─────────────►│                               │
   │  Authorization: Bearer <jwt>  │                               │
   │  X-Organization-Id: <uuid>    │                               │
   │                               │                               │
   │                               ├─ Validate JWT ───────────────►│
   │                               │                    ◄── JWKS   │
   │                               │                               │
   │                               ├─ Extract sub (externalAuthId) │
   │                               ├─ Load org → derive tenantId   │
   │                               │                               │
   │                               ├─ Look up User by              │
   │                               │  (externalId, tenantId)       │
   │                               │                               │
   │                               ├─ If not found:                │
   │                               │  AUTO-PROVISION new User      │
   │                               │  (role: MEMBER, status: ACTIVE)
   │                               │                               │
   │                               ├─ If found:                    │
   │                               │  Update profile (email, name) │
   │                               │                               │
   │  ◄── 200 { user data }        │                               │

Auto-provisioning rules:

  • New OIDC user → create app User with MEMBER role
  • Existing OIDC user → update profile, return existing User
  • Invited user (matched by email within tenant) → link identity, activate
  • No org header → 401

Token Refresh

Tokens expire after ~1 hour. The frontend refreshes transparently:

Frontend                        Zitadel
   │                               │
   ├─ Check: is access_token       │
   │  expiring within 60s?         │
   │                               │
   ├─ POST /oauth/v2/token ───────►│
   │    grant_type=refresh_token   │
   │    refresh_token=...          │
   │                               │
   │  ◄── { new access_token,      │
   │        new refresh_token }     │
   │                               │
   ├─ Replace stored tokens         │

A mutex prevents concurrent refresh requests when multiple API calls detect expiry simultaneously.

Logout Flow

Logout requires three steps for a complete sign-out:

1. Revoke refresh token (API call)

Frontend                        Zitadel
   │                               │
   ├─ POST /oauth/v2/revoke ──────►│
   │    token=<refresh_token>      │
   │    client_id=...              │
   │                               │
   │  ◄── 200 OK                   │

This prevents the refresh token from being used to obtain new access tokens. The current access token remains valid until it expires (~minutes), but without a refresh token, the session will end.

2. End Zitadel session (browser redirect)

Frontend                        Zitadel
   │                               │
   ├─ Redirect to ────────────────►│
   │  /oidc/v1/end_session?        │
   │    id_token_hint=<id_token>   │
   │    post_logout_redirect_uri=  │
   │      <app_url>                │
   │                               │
   │                               ├─ Clear session cookie
   │                               │
   │  ◄── Redirect back to app     │

Why is this needed? The Zitadel session is a browser cookie between the user and Zitadel. Without this step, clicking “Sign In” again would skip the login form because Zitadel still has an active session. This is the OIDC RP-Initiated Logout standard.

Platform-specific behavior:

  • Web: window.location.href redirect (tokens cleared before redirect)
  • Mobile: WebBrowser.openAuthSessionAsync (opens browser, returns to app)

3. Clear local tokens

Frontend

   ├─ Delete access_token, refresh_token, id_token
   │  from local storage

   ├─ Set auth state → 'unauthenticated'

Complete logout sequence

Frontend                        Zitadel
   │                               │
   ├─ 1. Revoke refresh token ────►│  (API call)
   │                               │
   ├─ 2. Clear local tokens        │
   │                               │
   ├─ 3. Redirect to end_session ─►│  (browser redirect)
   │                               │
   │                               ├─ Clear session cookie
   │                               │
   │  ◄── Redirect to app (logged  │
   │      out, login form shown)   │

Context Resolution

Every authenticated API request includes:

Authorization: Bearer <jwt>        → identity (who you are)
X-Organization-Id: <org-uuid>     → context (which org)

The tenant is derived server-side from org.tenant_id — never sent by the client. This means:

  • The client only tracks one context value (organizationId)
  • No inconsistency possible (can’t send wrong tenant for an org)
  • Org migration between tenants is transparent to the client

How the org is determined

PlatformSource
Webapp (subdomain){slug}.yourapp.appGET /api/v1/organizations/resolve/{slug}
Webapp (root domain)GET /api/v1/organizations/resolve/ (empty slug → root org)
Native appOrg picker after login

Invited User Flow

When an admin invites a user from the backend:

  1. Admin enters email + name → User::invite() creates user with status INVITED (no externalId)
  2. Invitation email sent with link to the org’s subdomain
  3. User clicks link → opens app → clicks “Sign In” → authenticates via Zitadel
  4. Backend receives JWT → extracts sub → no User found by externalId
  5. Fallback: look up by email within tenant → finds invited User
  6. User::linkIdentity() sets externalId, status → ACTIVE
  7. User is now fully authenticated

Security Notes

  • PKCE (Proof Key for Code Exchange) prevents authorization code interception
  • Refresh tokens are revoked on logout (best-effort)
  • Zitadel session is ended via browser redirect (prevents silent re-authentication)
  • JWT validation uses Zitadel’s JWKS endpoint with caching (1 hour TTL, auto-refresh on key rotation)
  • Auto-provisioning only creates MEMBER role users — admin roles require explicit assignment
  • Tenant isolation is enforced in the authenticator — a user in tenant A cannot access tenant B’s data, even with a valid JWT