ADR 015: Context Resolution via X-Organization-Id Header

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:

  1. Validates JWT → extracts sub (externalAuthId)
  2. Reads X-Organization-Id → loads org → derives tenantId from org.tenant_id
  3. Resolves User by (externalAuthId, tenantId) → auto-provisions if needed
  4. Validates user has membership in the org
  5. SecurityUser carries both tenantId (derived) and organizationId

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_id in 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:

  1. Validates the JWT → extracts sub (externalAuthId)
  2. Reads X-Organization-Id header → validates UUID format
  3. Loads Organization → derives tenantId from org.tenant_id
  4. Looks up User by (externalAuthId, tenantId) → auto-provisions if needed
  5. No User found → 401
  6. Validates user has OrgMembership → 403 if not
  7. SecurityUser loaded with tenantId, 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

PlatformHow org is determined
WebappSubdomain: {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_ID in ZitadelBearerAuthenticator must be removed
  • ProvisionUserFromOidcCommand receives tenantId derived from the org, not from a client header
  • X-Organization-Id is the only required context header for all authenticated API routes
  • No X-Tenant-Id header — tenant is always derived server-side from org.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.