Skip to content

How It Works

How Zitadel's event-sourced CQRS engine processes commands, projects read models, handles authentication sessions, and triggers extensibility actions.

Event Sourcing Pipeline

Zitadel's core architecture is built on Command Query Responsibility Segregation (CQRS) with event sourcing. Every state mutation flows through a strict pipeline:

flowchart LR
    subgraph Write["Write Path"]
        API["API Layer\n(gRPC / REST)"]
        Cmd["Command\nValidation"]
        ES["Eventstore\n(PostgreSQL)"]
    end

    subgraph Project["Projection Workers"]
        Sub["Event\nSubscriber"]
        RM["Read Model\nTables"]
    end

    subgraph Read["Read Path"]
        Query["Query\nHandlers"]
        Cache["Redis\nCache"]
    end

    API --> Cmd --> ES
    ES --> Sub --> RM
    Query --> Cache --> RM

Write Path — Command Processing

  1. API layer receives a gRPC or REST request, authenticates the caller via Bearer token
  2. Authorization check — the proto-annotated permission (e.g., user.write) is validated against the caller's memberships
  3. Command handler validates business rules (unique constraints, state preconditions, policy compliance)
  4. Event creation — the handler produces one or more domain events (e.g., user.human.added, user.human.password.changed)
  5. Atomic persistence — events are appended to the eventstore.events2 table in PostgreSQL using the custom eventstore.push function
  6. Unique constraint enforcement — PostgreSQL advisory locks (pg_advisory_xact_lock_shared) prevent concurrent creation of duplicate resources

Event Structure

Every event in the store contains:

Field Purpose
aggregate_type Entity category (user, org, project, etc.)
aggregate_id Unique entity identifier
event_type Specific mutation (e.g., user.human.added)
sequence Per-aggregate monotonically increasing counter
position Global ordering across all aggregates
instance_id Tenant isolation key
creation_date Timestamp
payload Protocol Buffer encoded event data

Read Path — Projection Workers

  1. Background projections subscribe to the event stream
  2. Each projection builds an optimized read-model table (e.g., projections.users, projections.orgs, projections.project_roles)
  3. Projections track their last processed position, enabling idempotent replay
  4. Query handlers serve API reads from projection tables
  5. Redis cache (optional) reduces database load for hot queries

Authentication Session Lifecycle

OIDC Authorization Code Flow (with PKCE)

sequenceDiagram
    participant User as Browser
    participant App as Application
    participant ZA as Zitadel
    participant ES as Eventstore

    User->>App: Click "Login"
    App->>ZA: GET /oauth/v2/authorize?client_id=...&redirect_uri=...&code_challenge=...
    ZA->>User: Show Login UI (passkey / password / IdP)
    User->>ZA: Authenticate (WebAuthn / password / MFA)
    ZA->>ES: Append session.created, auth.succeeded events
    ZA->>App: 302 Redirect with authorization code
    App->>ZA: POST /oauth/v2/token (code + code_verifier)
    ZA->>ES: Append token.issued event
    ZA->>App: Access token + ID token + refresh token
    App->>ZA: GET /api (Authorization: Bearer <access_token>)
    ZA->>App: JSON response

Session Model (V2 API)

Zitadel's V2 Session API provides fine-grained session management:

  1. Session creationPOST /v2/sessions creates a new session with an optional initial check (password, passkey, or IdP)
  2. Session updatesPUT /v2/sessions/{id} adds further checks (MFA step, TOTP verification)
  3. Session token — returned after successful authentication, used for stateless session tracking
  4. Session terminationDELETE /v2/sessions/{id} invalidates the session

Passkey Authentication Flow

sequenceDiagram
    participant Browser as Browser
    participant ZA as Zitadel Server
    participant Auth as Authenticator\n(Platform/Roaming)

    Browser->>ZA: POST /v2/sessions (user identifier)
    ZA->>ZA: Generate WebAuthn challenge
    ZA->>Browser: Return challenge + allowed credentials
    Browser->>Auth: navigator.credentials.get({challenge, ...})
    Auth->>Auth: User verification (biometric / PIN)
    Auth->>Browser: Signed assertion
    Browser->>ZA: POST /v2/sessions/{id} (assertion response)
    ZA->>ZA: Verify signature against stored public key
    ZA->>ZA: Append session.checked.webauthn event
    ZA->>Browser: Session token

Authorization Resolution

When an API request arrives, Zitadel resolves permissions through:

  1. Token introspection — extract user ID and scope from the Bearer token
  2. Membership lookup — query the user's memberships across organizations, projects, and IAM scopes
  3. Role resolution — for each membership, resolve the assigned roles and their associated permissions
  4. Context filtering — if the permission requires a context (e.g., project.write:project-123), verify the target resource matches
  5. Grant policy check — verify the request passes the organization's policy constraints
flowchart TB
    Req["Incoming API Request"]
    Token["Token Introspection"]
    Member["Membership\nLookup"]
    Roles["Role\nResolution"]
    Perms["Permission\nExpansion"]
    Check["Context\nCheck"]
    Allow{"Allowed?"}

    Req --> Token --> Member --> Roles --> Perms --> Check --> Allow
    Allow -->|Yes| Handler["API Handler"]
    Allow -->|No| Deny["403 Permission Denied"]

Actions & Webhooks Pipeline

Zitadel's extensibility layer intercepts authentication and registration flows:

  1. Trigger point — a defined event (e.g., pre creation, post creation, pre userinfo, post authentication) fires
  2. Action execution — JavaScript functions (V2 Actions) or HTTP targets are invoked
  3. Request modification — actions can modify tokens, add claims, block requests, or redirect users
  4. Webhook delivery — events are POSTed to configured HTTP endpoints with full event payload
  5. Error handling — failed actions can be configured to block or allow the flow

Action Execution Context

// Example V2 Action: Add custom claims to token
function addAction(token, api) {
    // token contains current JWT claims
    // api provides methods to modify the response
    api.v1.user.setMetadata("custom_role", "premium");
    // Block the request if condition not met
    if (!isAuthorized(token.user)) {
        api.v1.blockRequest("User not authorized");
    }
}

Multi-Tenancy Hierarchy

Zitadel enforces a strict hierarchy for resource isolation:

flowchart TB
    Instance["Instance\n(Self-hosted deployment\nor Zitadel Cloud tenant)"]
    Org1["Organization A\n(Tenant)"]
    Org2["Organization B\n(Tenant)"]
    Proj1A["Project: Billing API"]
    Proj1B["Project: User Portal"]
    Proj2A["Project: Partner Portal"]
    App1["App: Web Client\n(OIDC)"]
    App2["App: Mobile App\n(OIDC)"]
    App3["App: API Service\n(SAML)"]
    Roles1["Roles: admin, editor, viewer"]
    Grant1["Project Grant\n→ Org B"]

    Instance --> Org1
    Instance --> Org2
    Org1 --> Proj1A
    Org1 --> Proj1B
    Org2 --> Proj2A
    Proj1A --> App1
    Proj1A --> App2
    Proj1B --> App3
    Proj1A --> Roles1
    Proj1A -.->|"Delegates\nadmin, editor"| Grant1
    Grant1 -.-> Org2

Project Grants enable cross-organization authorization: Organization A defines roles on a project and grants a subset to Organization B. Organization B can then self-manage user assignments within the delegated roles.

Projection Rebuilding

Because all state is derived from events, read models can be rebuilt from scratch:

  1. Truncate the projection table
  2. The projection worker replays all events from position 0
  3. The read model is reconstructed to match the current event log
  4. This enables zero-downtime schema migrations and data repairs

Sources