ADR 015: Context Resolution via X-Organization-Id Header
Status
Accepted (updated 2026-03-12: simplified to single X-Organization-Id header — tenant derived server-side from org)
Date
2026-03-12
Context
The platform is multi-tenant. Users authenticate via Zitadel (external OIDC provider), which manages identity — it has no knowledge of our tenant model. A single Zitadel account (one sub / externalAuthId) can map to multiple User rows across different tenants.
The backend needs to know which organization (and by extension, which tenant) a request targets. The JWT from Zitadel only proves who the user is, not which context they’re operating in.
Options considered
A. Tenant IDs in JWT (Zitadel custom claims) Rejected. Couples the identity provider to the app’s tenant model. Every tenant membership change requires syncing to Zitadel. Creates a single point of failure and consistency nightmare.
B. App-issued secondary JWT with tenantId baked in Rejected. Adds a token exchange endpoint (attack surface), refresh logic (complexity), and a second token to manage. Tenant switching requires re-issuing tokens.
C. X-Tenant-Id + X-Organization-Id headers (two context headers)
Rejected. The tenant is always derivable from the organization (org.tenant_id). Requiring both headers is redundant and creates a consistency problem — what if the client sends a valid org but the wrong tenant?
D. X-Organization-Id header as single context selector
Accepted. The client declares which organization it’s operating in. The server derives the tenant from org.tenant_id. One header, no ambiguity, no consistency issues.
Decision
1. Two-coordinate authentication: identity + org context
Every authenticated API request carries two pieces of information:
Authorization: Bearer <zitadel-jwt> → proves identity (who you are)
X-Organization-Id: <org-uuid> → context (which org you're operating in)
The tenant is derived server-side from org.tenant_id — never sent by the client.
The authenticator:
- Validates JWT → extracts
sub(externalAuthId) - Reads
X-Organization-Id→ loads org → derivestenantIdfromorg.tenant_id - Resolves User by
(externalAuthId, tenantId)→ auto-provisions if needed - Validates user has membership in the org
SecurityUsercarries bothtenantId(derived) andorganizationId
Content queries use organizationId. Identity queries use tenantId (derived from org).
2. Why tenant derivation is better than a client header
- No inconsistency: Client can’t send a valid org with the wrong tenant
- Simpler client: Frontend only needs to track
organizationId, not both - Single source of truth:
org.tenant_idin the database is authoritative - Org migration safe: When an org moves between tenants, the client doesn’t need updating — the server derives the new tenant automatically
3. Validation happens inside the authenticator
The X-Organization-Id header is untrusted client input, validated as part of authentication:
- Validates the JWT → extracts
sub(externalAuthId) - Reads
X-Organization-Idheader → validates UUID format - Loads Organization → derives
tenantIdfromorg.tenant_id - Looks up User by
(externalAuthId, tenantId)→ auto-provisions if needed - No User found → 401
- Validates user has OrgMembership → 403 if not
SecurityUserloaded withtenantId,organizationId, and roles
There is no separate subscriber. The org/tenant check IS authentication.
4. API key authentication is unchanged
API keys are bound to a single tenant. The ApiKeyAuthenticator resolves the tenant from the key itself. X-Organization-Id can optionally scope content queries within that tenant.
5. Unauthenticated routes
Public endpoints (e.g., org resolution, branding) use the slug in the URL path — no headers needed. Example: GET /api/v1/organizations/resolve/{slug} returns organizationId, tenantId, name, branding. The client uses the returned organizationId for subsequent authenticated requests.
6. Context source varies by platform
| Platform | How org is determined |
|---|---|
| Webapp | Subdomain: {slug}.yourapp.app → org resolve → gets organizationId |
| Native app (denomination) | Org picker within denomination tenant |
| Native app (platform) | Org picker after login |
The frontend resolves an organization by slug. The resolve response includes organizationId (and tenantId for informational purposes). Only organizationId is sent on API requests.
7. Tenant/org switching requires no re-authentication
The Zitadel JWT stays valid across org and tenant switches. Only the X-Organization-Id header value changes. If the new org belongs to a different tenant, the backend automatically resolves a different User row for the same identity. This is seamless for:
- Native platform app: user switches between orgs (possibly across tenants) in a picker
- Future: deep links that cross tenant boundaries
Consequences
- The hardcoded
DEFAULT_TENANT_IDinZitadelBearerAuthenticatormust be removed ProvisionUserFromOidcCommandreceivestenantIdderived from the org, not from a client headerX-Organization-Idis the only required context header for all authenticated API routes- No
X-Tenant-Idheader — tenant is always derived server-side fromorg.tenant_id - Admin backend (session-based) continues to derive tenant from the session — no header needed
- Rate limiting should be applied to the public slug resolution endpoint to prevent enumeration
- No Zitadel configuration changes needed — identity and tenancy remain cleanly separated
Relationship to other ADRs
- ADR 013 (Tenant Scoping): Remains valid. Once the authenticator derives the tenant from the org, all downstream scoping works as defined in ADR 013.
- ADR 014 (Registration Workflows): The “join” flow uses this same pattern — the org is known from the subdomain before the user authenticates.
- ADR 016 (Platform Tenant): The default app resolves to the platform tenant’s root org. From there, the tenant is derived like any other org.