Security Guidelines: Authentication & Access Control

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-Id is 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:

PlatformSourceUser choice?
WebappSubdomain ({slug}.yourapp.app) → resolved to org UUID via public endpointNo
Native app (denomination)Org picker within denomination tenantYes
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-Id needed
  • 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-Id header — the session IS the tenant context
  • Org context: OrgContextListener reads active_organization_id from session and enriches the token with the user’s org role
  • Admin panel sets active_organization_id in 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 methodOrg sourceTenant source
Bearer + X-Organization-IdHeader (validated)Derived from org.tenant_id
API KeyKey itself (bound to one org)Derived from org.tenant_id
Admin sessionSession (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)

LayerScopeUsed forRepository signature
TenantIdentity & isolationUsers, auth, API keysfindById(UserId $id, ?TenantId $tenantId)
OrganizationContent & membershipGroups, Events, News, MediafindById(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-Id header 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:

EndpointPurposeContext
GET /api/v1/organizations/resolve/{slug}Slug → org UUID + tenant UUIDNone (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: SuperAdminIpRestrictionListener blocks access from unauthorized IPs
  • Cross-tenant access: ROLE_SUPER_ADMIN can view/manage all tenants in the admin UI
  • Impersonation: Symfony switch_user enabled — 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 valueSymfony rolePurpose
SUPER_ADMINROLE_SUPER_ADMINPlatform operator. No tenant. Cross-tenant access in admin UI.
TENANT_ADMINROLE_TENANT_ADMINManages all orgs within their tenant.
USERROLE_USERDefault role for all authenticated users.

Org Roles (OrgRole enum)

Stored on OrgMembership. Determine per-organization access.

Enum valueSymfony role (when active)Purpose
adminROLE_ORG_ADMINManages 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_ADMIN is an abstract “has admin access” role — granted by either ROLE_TENANT_ADMIN or ROLE_ORG_ADMIN
  • /admin access_control requires ROLE_ADMIN — org admins can reach the admin area
  • Individual admin controllers still require ROLE_TENANT_ADMIN because they query tenant-wide data. Org-scoped admin views will be added as separate controllers.
  • /superadmin/* routes require ROLE_SUPER_ADMIN
  • Super admin inherits all lower roles but has no tenant — admin panel features are tenant-scoped

How Org Context is Resolved

Auth methodOrg role sourceWhen
Bearer + X-Organization-IdOrgMembershipQuery in ZitadelBearerAuthenticatorAt authentication time
Admin sessionOrgContextListener reads active_organization_id from sessionOn every web request
API KeyBound 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:

VoterAttributeSubjectGrants access when
OrgAccessVoterORG_ADMINorg UUID stringUser is org admin for that org, or has ROLE_TENANT_ADMIN/ROLE_SUPER_ADMIN
OrgMemberVoterORG_MEMBERorg UUID stringUser 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

  1. Org role is never client-supplied. The X-Organization-Id header selects the org; the role comes from OrgMembership in the DB.
  2. Tenant admin > org admin. A tenant admin has admin access to all orgs in their tenant via role hierarchy.
  3. Super admin > tenant admin. Super admin can access everything but has no tenant — scoped features fall back to the selected tenant/org.
  4. No privilege escalation via org switching. Changing X-Organization-Id triggers a fresh OrgMembership lookup. Being admin in Org A grants nothing in Org B.
  5. Voters check real membership, not cached roles. OrgAccessVoter and OrgMemberVoter query 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) or tenant_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.yaml with PUBLIC_ACCESS before 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-Id is 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