Security Guidelines: Authentication & Access Control
This is the authoritative reference for how authentication, tenant resolution, and access control work in the platform. All agents and contributors must follow these rules.
Authentication Model
The platform has three authentication mechanisms. Each resolves a SecurityUser with a tenantId (derived) and organizationId.
1. OIDC Bearer Token + X-Organization-Id (App API)
Used by: webapp, native app — all /api/* routes requiring auth.
Client sends:
Authorization: Bearer <zitadel-jwt> → proves identity
X-Organization-Id: <org-uuid> → declares org context
Server validates:
1. JWT signature + expiry (via Zitadel JWKS)
2. Extract sub (externalAuthId) from JWT
3. Validate X-Organization-Id is a valid UUID
4. Load Organization → derive tenantId from org.tenant_id
5. Look up User WHERE external_auth_id = sub AND tenant_id = derived tenantId
6. No User found → auto-provision or 401
7. User suspended → 403
8. Validate user has OrgMembership in the org → 403 if not
9. User active + org member → SecurityUser loaded with tenant roles + org context
Key rules:
X-Organization-Idis untrusted client input, validated inside the authenticator- The JWT proves who you are (Zitadel identity); the header declares which org you’re operating in
- The tenant is derived server-side from
org.tenant_id— never sent by the client - Tenant + org validation is part of authentication, not a separate middleware/subscriber
- Zitadel has no knowledge of our tenant or org model — identity and tenancy are cleanly separated
- See ADR 015 for the full rationale
How the frontend determines the org:
| Platform | Source | User choice? |
|---|---|---|
| Webapp | Subdomain ({slug}.yourapp.app) → resolved to org UUID via public endpoint | No |
| Native app (denomination) | Org picker within denomination tenant | Yes |
| Native app (platform) | Org picker after login (GET /api/v1/me/organizations) | Yes |
2. API Key (Public API)
Used by: external integrations — /api/v1/* routes.
Client sends:
X-API-Key: chapp_{prefix}_{secret}
Server validates:
1. Parse prefix from key
2. Look up ApiKey by prefix
3. Verify hash
4. Check active + not expired
5. SecurityUser loaded with ApiKey's organizationId + derived tenantId
- API keys are scoped to exactly one organization — no
X-Organization-Idneeded - The key grants access to the bound org plus all sub-orgs (descendants in the ltree)
- The tenant is derived from
org.tenant_id, same as OIDC auth - Keys are hashed at rest; raw key shown only once at creation
- Authenticator:
ApiKeyAuthenticator
3. OIDC Session (Admin Backend)
Used by: admin UI at /admin/* — server-rendered Twig pages.
Flow:
1. User clicks "Sign in" → OIDC redirect to Zitadel (PKCE + state)
2. Zitadel authenticates → callback with authorization code
3. Backend exchanges code for tokens
4. Look up or provision User by externalAuthId
5. Symfony session created → SecurityUser in session
- Tenant comes from the User entity, stored in the session
- No
X-Organization-Idheader — the session IS the tenant context - Org context:
OrgContextListenerreadsactive_organization_idfrom session and enriches the token with the user’s org role - Admin panel sets
active_organization_idin session via org switcher - Authenticator:
ZitadelOidcAuthenticator
Org & Tenant Context Rules
The tenant is NEVER client-supplied
Clients never send tenantId in headers, POST/PUT/PATCH bodies, or query parameters. The tenant enters the system through exactly one path: derived from the organization.
| Auth method | Org source | Tenant source |
|---|---|---|
| Bearer + X-Organization-Id | Header (validated) | Derived from org.tenant_id |
| API Key | Key itself (bound to one org) | Derived from org.tenant_id |
| Admin session | Session (from User entity at login) | Session (from User entity) |
Context flow through the stack
Authenticator
→ Loads Organization from X-Organization-Id
→ Derives tenantId from org.tenant_id
→ SecurityUser carries both tenantId + organizationId
→ Controller extracts from SecurityUser
→ Content commands carry organizationId
→ Identity commands carry tenantId (derived)
→ Repository filters by appropriate scope
Every layer enforces the same context. Defense in depth — even if one layer is bypassed, the next catches it.
Two scoping layers (see ADR 013)
| Layer | Scope | Used for | Repository signature |
|---|---|---|---|
| Tenant | Identity & isolation | Users, auth, API keys | findById(UserId $id, ?TenantId $tenantId) |
| Organization | Content & membership | Groups, Events, News, Media | findById(GroupId $id, OrganizationId $orgId) |
Content tables use organization_id, not tenant_id. The tenant is always derivable: content → org → org.tenant_id.
Multi-tenant users
A single Zitadel account (one externalAuthId) can have User rows in multiple tenants. Each User row has its own roles, memberships, and data. Org/tenant switching:
- The Zitadel JWT remains valid across switches
- Only the
X-Organization-Idheader changes - If the new org belongs to a different tenant, the backend resolves a different User row
- No re-authentication needed
Tenant Isolation
Repository-level enforcement
Identity repositories require TenantId, content repositories require OrganizationId:
// Identity — tenant-scoped
public function findById(UserId $id, ?TenantId $tenantId = null): ?User;
// Content — org-scoped
public function findById(GroupId $id, OrganizationId $orgId): ?Group;
public function findById(EventId $id, OrganizationId $orgId): ?Event;
Mismatched scope returns null — same as “not found”. No “exists but wrong org/tenant” errors. This prevents resource enumeration.
Command-level enforcement
Content write commands carry organizationId, identity write commands carry tenantId:
// Content command — org-scoped
final readonly class UpdateGroupCommand
{
public function __construct(
public string $organizationId, // from SecurityUser, not from request
public string $groupId,
// ...
) {}
}
// Identity command — tenant-scoped (derived from org)
final readonly class SuspendUserCommand
{
public function __construct(
public string $tenantId, // derived from org.tenant_id via SecurityUser
public string $userId,
) {}
}
Cross-module references
Modules reference entities from other modules by string (UUID), not by importing the other module’s ID type. This prevents module coupling while maintaining isolation — every module enforces its own scope independently.
Public (Unauthenticated) Endpoints
Some endpoints are accessible without authentication:
| Endpoint | Purpose | Context |
|---|---|---|
GET /api/v1/organizations/resolve/{slug} | Slug → org UUID + tenant UUID | None (slug is the input) |
Rules for public endpoints:
- Invalid/nonexistent slug → 404 (no information leakage)
- Rate limit by IP to prevent slug enumeration
- Never expose sensitive data (user counts, internal IDs beyond the org/tenant UUIDs)
Superadmin
- IP-restricted:
SuperAdminIpRestrictionListenerblocks access from unauthorized IPs - Cross-tenant access:
ROLE_SUPER_ADMINcan view/manage all tenants in the admin UI - Impersonation: Symfony
switch_userenabled — superadmin can impersonate tenant admins - API access: Superadmin does NOT bypass org/tenant scoping on API routes — this is admin-only
Role Model
The platform has two layers of roles: user roles (platform-wide, stored on the User entity) and org roles (per-organization, stored on OrgMembership). Both layers are wired into Symfony security.
User Roles (UserRole enum)
Stored on the User entity. Determine platform-level access.
| Enum value | Symfony role | Purpose |
|---|---|---|
SUPER_ADMIN | ROLE_SUPER_ADMIN | Platform operator. No tenant. Cross-tenant access in admin UI. |
TENANT_ADMIN | ROLE_TENANT_ADMIN | Manages all orgs within their tenant. |
USER | ROLE_USER | Default role for all authenticated users. |
Org Roles (OrgRole enum)
Stored on OrgMembership. Determine per-organization access.
| Enum value | Symfony role (when active) | Purpose |
|---|---|---|
admin | ROLE_ORG_ADMIN | Manages this specific organization. |
member | (none extra) | Regular member of the organization. |
When a user operates in an org context (via X-Organization-Id header for API, or session for admin), SecurityUser is enriched with the org role. If the user is an org admin, ROLE_ORG_ADMIN is added to their Symfony roles.
Symfony Role Hierarchy
ROLE_SUPER_ADMIN: [ROLE_TENANT_ADMIN, ROLE_USER]
ROLE_TENANT_ADMIN: [ROLE_ADMIN, ROLE_USER]
ROLE_ORG_ADMIN: [ROLE_ADMIN, ROLE_USER]
ROLE_ADMIN: [ROLE_USER]
ROLE_ADMINis an abstract “has admin access” role — granted by eitherROLE_TENANT_ADMINorROLE_ORG_ADMIN/adminaccess_control requiresROLE_ADMIN— org admins can reach the admin area- Individual admin controllers still require
ROLE_TENANT_ADMINbecause they query tenant-wide data. Org-scoped admin views will be added as separate controllers. /superadmin/*routes requireROLE_SUPER_ADMIN- Super admin inherits all lower roles but has no tenant — admin panel features are tenant-scoped
How Org Context is Resolved
| Auth method | Org role source | When |
|---|---|---|
| Bearer + X-Organization-Id | OrgMembershipQuery in ZitadelBearerAuthenticator | At authentication time |
| Admin session | OrgContextListener reads active_organization_id from session | On every web request |
| API Key | Bound to one org; no org role (API keys use ROLE_API) | At authentication time |
The org role is always derived server-side from OrgMembership — never trusted from the client.
Authorization Voters
For fine-grained per-resource access control beyond simple role gates:
| Voter | Attribute | Subject | Grants access when |
|---|---|---|---|
OrgAccessVoter | ORG_ADMIN | org UUID string | User is org admin for that org, or has ROLE_TENANT_ADMIN/ROLE_SUPER_ADMIN |
OrgMemberVoter | ORG_MEMBER | org UUID string | User has any membership in that org, or has ROLE_TENANT_ADMIN/ROLE_SUPER_ADMIN |
Usage in controllers:
#[IsGranted('ORG_ADMIN', subject: 'organizationId')]
#[IsGranted('ORG_MEMBER', subject: 'organizationId')]
Privilege Hierarchy (effective)
ROLE_SUPER_ADMIN → platform-wide (admin UI only, no tenant)
└── ROLE_TENANT_ADMIN → all orgs within a tenant (inherits ROLE_ADMIN)
└── ROLE_ORG_ADMIN → this org only (inherits ROLE_ADMIN)
└── ROLE_USER → basic authenticated access
Security Invariants
- Org role is never client-supplied. The
X-Organization-Idheader selects the org; the role comes fromOrgMembershipin the DB. - Tenant admin > org admin. A tenant admin has admin access to all orgs in their tenant via role hierarchy.
- Super admin > tenant admin. Super admin can access everything but has no tenant — scoped features fall back to the selected tenant/org.
- No privilege escalation via org switching. Changing
X-Organization-Idtriggers a freshOrgMembershiplookup. Being admin in Org A grants nothing in Org B. - Voters check real membership, not cached roles.
OrgAccessVoterandOrgMemberVoterquery the DB, so membership changes take effect immediately.
Checklist for New Endpoints
Authenticated endpoints
- Controller injects
#[CurrentUser] SecurityUser $user -
$user->getOrganizationId()passed to content commands/queries -
$user->getTenantId()passed to identity commands/queries - Command/query handler passes appropriate scope to all repository calls
- Repository implementation filters by
organization_id(content) ortenant_id(identity) - No endpoint returns data without scope filtering
- Error responses don’t leak cross-org or cross-tenant information
Public endpoints
- Registered in
security.yamlwithPUBLIC_ACCESSbefore the^/api/rule - Rate limited (IP-based)
- Returns 404 for invalid/nonexistent orgs (not 400 or distinct error)
- No sensitive data exposed
Data Protection
Sensitive data handling
- Never commit
.env.local, credentials, or private keys - API keys hashed at rest; raw key shown only once at creation
- OIDC client secrets in environment variables, not in code
- Zitadel system API key stored in
infrastructure/docker/, never in app code
Input validation
- Validate at the boundary (controllers, command constructors)
- Use value objects for domain-specific validation (
Email,PhoneNumber,Timezone) - Never trust client-provided IDs for authorization — always verify via scoped queries
X-Organization-Idis validated inside the authenticator, not assumed safe
OWASP Considerations
- Injection: Doctrine parameterized queries everywhere; no raw SQL concatenation
- Broken Access Control: Three-layer defense: authenticator (org→tenant derivation) → command (scope parameter) → repository (WHERE clause)
- CSRF: Admin forms use Symfony CSRF tokens; API endpoints are stateless (no CSRF needed)
- CORS: Configured via NelmioCorsBundle; wildcard subdomains for multi-tenant webapp
- Security Misconfiguration: Superadmin IP-restricted; public endpoints rate-limited; no debug info in production error responses
- Identification & Authentication: OIDC with PKCE; JWT validated via JWKS with rotation support; API keys with prefix-based lookup + hash verification