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
| Component | Role |
|---|---|
| Zitadel | Identity 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 header | Context 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
MEMBERrole - 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.hrefredirect (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
| Platform | Source |
|---|---|
| Webapp (subdomain) | {slug}.yourapp.app → GET /api/v1/organizations/resolve/{slug} |
| Webapp (root domain) | GET /api/v1/organizations/resolve/ (empty slug → root org) |
| Native app | Org picker after login |
Invited User Flow
When an admin invites a user from the backend:
- Admin enters email + name →
User::invite()creates user with statusINVITED(no externalId) - Invitation email sent with link to the org’s subdomain
- User clicks link → opens app → clicks “Sign In” → authenticates via Zitadel
- Backend receives JWT → extracts
sub→ no User found by externalId - Fallback: look up by email within tenant → finds invited User
User::linkIdentity()sets externalId, status →ACTIVE- 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
MEMBERrole 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