Skip to content

How-to Guides

Tasks covered

Deploy Zitadel with Docker Compose or Helm, connect it to managed PostgreSQL, put it behind a proxy, enable caching and observability, upgrade (including v3 to v4 and CockroachDB to PostgreSQL), back up, call the v2 APIs, use the SDKs and Terraform, wire up Actions V2, and troubleshoot. Commands follow the official docs as of v4.19 (2026-09). Background is in Explanation; versions, paths, and config keys are in Reference.

Pin a patched version

Several critical and high advisories were fixed in July to September 2026. Use v4.19.1 or later, skip v4.18.0 (setup step fails), and do not deploy new v3 installs (end of life). See Reference.

Deployment

Docker Compose (Quick Start)

The official compose pack runs Traefik, the Zitadel API, the Login V2 container, and PostgreSQL:

mkdir zitadel-compose && cd zitadel-compose
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
cp .env.example .env
docker compose up -d --wait

Open http://localhost:8080. The first admin is zitadel-admin@zitadel.localhost with password Password1! unless you set ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD before the first start.

Pinned versions

.env.example pins ZITADEL_VERSION (it was v4.16.0 when checked), plus Traefik, PostgreSQL 17, Redis 7.4, and the OTel collector. Bump ZITADEL_VERSION to the latest patched release before you go past a laptop test.

Harden the Compose Deployment

Generate secrets before the first start. The masterkey must be exactly 32 characters and cannot be changed later:

ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .env
echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
echo "ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD='Change-Me-1!'" >> .env

Set your domain and a TLS overlay (mode-letsencrypt, mode-external-tls, or mode-local-tls):

# in .env: ZITADEL_DOMAIN=auth.example.com, ZITADEL_EXTERNALPORT=443, ZITADEL_EXTERNALSECURE=true
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
docker compose --env-file .env \
  -f docker-compose.yml \
  -f docker-compose.mode-letsencrypt.yml \
  up -d --wait

For controlled upgrades and scaling, add the prodlike overlay, which splits zitadel-init, zitadel-setup, and zitadel-api:

curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.prodlike.yml
docker compose --env-file .env -f docker-compose.yml -f docker-compose.prodlike.yml \
  up -d --scale zitadel-api=3

Kubernetes via Helm (Quickstart)

Requires Kubernetes 1.30+, Helm 3 or 4, and an ingress or Gateway API controller. The chart can deploy a bundled PostgreSQL for testing:

curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel-charts/main/examples/0-quickstart/quickstart-values.yaml
# edit ExternalDomain, ExternalPort and both ingress className values
helm repo add zitadel https://charts.zitadel.com
helm repo update
helm upgrade --install zitadel zitadel/zitadel --values quickstart-values.yaml --wait

Kubernetes via Helm (Production)

Create the masterkey and database secrets first:

kubectl create namespace zitadel
kubectl -n zitadel create secret generic zitadel-masterkey \
  --from-literal=masterkey="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
kubectl -n zitadel create secret generic zitadel-db-credentials \
  --from-literal=dsn="postgresql://zitadel:REPLACE_ME@postgres.database.svc.cluster.local:5432/postgres?sslmode=verify-full"

Minimal production values.yaml (from the official Kubernetes guide), with ingress for both the API and Login V2:

replicaCount: 2

zitadel:
  masterkeySecretName: zitadel-masterkey
  env:
    - name: ZITADEL_DATABASE_POSTGRES_DSN
      valueFrom:
        secretKeyRef:
          name: zitadel-db-credentials
          key: dsn
  configmapConfig:
    ExternalDomain: "auth.example.com"
    ExternalSecure: true
    ExternalPort: 443
    TLS:
      Enabled: false        # TLS terminated at the ingress
    FirstInstance:
      Org:
        Human:
          UserName: "admin"
          Email: "admin@example.com"
          PasswordChangeRequired: true

podDisruptionBudget:
  enabled: true
  minAvailable: 1

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: auth.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: zitadel-tls
      hosts: [auth.example.com]

login:
  ingress:
    enabled: true
    className: nginx
    hosts:
      - host: auth.example.com
        paths:
          - path: /ui/v2/login
            pathType: Prefix
    tls:
      - secretName: zitadel-tls
        hosts: [auth.example.com]
helm install zitadel zitadel/zitadel -n zitadel --values values.yaml --wait
kubectl -n zitadel get pods --watch   # zitadel-init and zitadel-setup Jobs complete, then Deployments go Ready

Ingress must speak HTTP/2 to the API

The Console and gRPC clients need end-to-end HTTP/2. With ingress-nginx this means the backend protocol for the zitadel service must be gRPC/h2c (for example the nginx.ingress.kubernetes.io/backend-protocol: "GRPC" annotation). Traefik handles h2c when the service scheme is h2c. With Gateway API, route /ui/v2/login to zitadel-login and / to zitadel, and make sure the controller supports h2c backends.

Lifecycle Phases

Zitadel separates one-time initialization, per-version setup, and runtime:

flowchart LR
    Init["zitadel init<br/>(once: role, database,<br/>eventstore/projections/system schemas)"]
    Setup["zitadel setup<br/>(every new version:<br/>migrations, optional --init-projections)"]
    Runtime["zitadel start<br/>(serves traffic, stateless,<br/>scale horizontally)"]

    Init --> Setup --> Runtime
    Setup -->|"next upgrade"| Setup

start-from-init and start-from-setup combine the phases for simple setups. The Helm chart runs init and setup as Jobs.

Configuration

Connect to Managed PostgreSQL (No Superuser)

On RDS, Cloud SQL, or Azure Database, provision the role and database yourself, then run only the schema step:

CREATE ROLE zitadel LOGIN PASSWORD 'REPLACE_ME';
CREATE DATABASE zitadel WITH OWNER zitadel;
export ZITADEL_DATABASE_POSTGRES_DSN='postgresql://zitadel:REPLACE_ME@db.example.com:5432/zitadel?sslmode=require'
zitadel init schema
zitadel start-from-setup --masterkey "$ZITADEL_MASTERKEY" --tlsMode external

Supported PostgreSQL versions are 14 to 18 (18 needs Zitadel v4.11.0+). Running init again with a new user does not migrate object ownership; rotate credentials by changing the password or reassigning ownership manually.

External URL Settings

ExternalDomain, ExternalPort, and ExternalSecure must match what users type in the browser. They are applied during setup. A mismatch is the most common cause of "Instance not found" errors.

Setting Env var Example
ExternalDomain ZITADEL_EXTERNALDOMAIN auth.example.com
ExternalPort ZITADEL_EXTERNALPORT 443
ExternalSecure ZITADEL_EXTERNALSECURE true

More keys are listed in Reference.

TLS Modes

--tlsMode Use when
disabled Local development only (HTTP everywhere)
external A proxy or ingress terminates TLS and forwards h2c to Zitadel (most common)
enabled Zitadel itself serves TLS (TLS.KeyPath/TLS.CertPath or inline TLS.Key/TLS.Cert)

NGINX Reverse Proxy Configuration

Official example for TLS mode external: grpc_pass sends h2c to the API, and plain proxy_pass sends the login path to the Next.js container.

server {
    listen 443 ssl;
    http2 on;
    ssl_certificate     /etc/nginx/tls/tls.crt;
    ssl_certificate_key /etc/nginx/tls/tls.key;

    location /ui/v2/login {
        proxy_pass http://zitadel-login:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }

    location / {
        grpc_pass grpc://zitadel:8080;
        grpc_set_header Host $host;
        grpc_set_header X-Forwarded-Proto https;
    }
}

Scaling

Horizontal Scaling

The binary is stateless; all shared state is in PostgreSQL. Scale replicas freely once init and setup run separately:

kubectl -n zitadel scale deployment zitadel --replicas=5
kubectl -n zitadel autoscale deployment zitadel --min=3 --max=10 --cpu-percent=70

Plan CPU for password hashing (bcrypt cost 14 by default): the production guide recommends 4 cores available for hashing spikes. Scale-to-zero platforms (Knative, Cloud Run) work if startup stays fast, which is another reason to run setup separately.

Database Scaling

Strategy Notes
Vertical first Roughly 1 PostgreSQL core per 100 req/s and 4 GB RAM per core (production guide)
Connection pool Tune MaxOpenConns/MaxIdleConns in the DSN config; add PgBouncer if many replicas
HA Patroni, CloudNativePG, or a managed service with failover
Multi-region Independent clusters with PostgreSQL read replicas; steer traffic by path or host

Enable Caching

Caches are off by default. Pick a connector per object (instance, milestones, organization). With Redis (standalone only):

Caches:
  Connectors:
    Redis:
      Enabled: true
      URL: redis://redis.zitadel.svc.cluster.local:6379   # rediss:// for TLS
  Instance:
    Connector: redis
  Milestones:
    Connector: redis
  Organization:
    Connector: redis

Without Redis, the docs suggest memory for instance and organization objects (short max age) and postgres (unlogged tables) for milestones. A circuit breaker bypasses Redis if it fails.

Monitoring

Health Checks

curl -fsS http://zitadel:8080/debug/healthz      # liveness
curl -fsS http://zitadel:8080/debug/ready        # readiness, checks the database
curl -fsS http://zitadel-login:3000/ui/v2/login/healthy
zitadel ready                                    # CLI check used by the compose healthcheck

Traces, Metrics, and Logs

Configure the Instrumentation section (the older Tracing and Metrics sections are deprecated):

Instrumentation:
  ServiceName: zitadel
  Trace:
    Fraction: 0.1
    Exporter:
      Type: grpc
      Endpoint: otel-collector.observability.svc.cluster.local:4317
      Insecure: true
  Metric:
    Exporter:
      Type: prometheus     # or grpc / http to push OTLP
  Log:
    Level: INFO

Enable access logs with LogStore.Access.Stdout.Enabled: true. The compose pack has an observability profile with an OTel collector (ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=grpc).

What to Watch

Signal Why
API p99 latency and error rate User-facing health
Login failures and lockouts Attacks or misconfigured policies
Rows in projections.failed_events Projections that skipped events
Projection lag after upgrades Stale reads until catch-up completes
PostgreSQL CPU and connections Database is the bottleneck in most deployments
Redis circuit breaker state Cache disabled, more database load

Thresholds depend on your traffic; Zitadel publishes no official alert values.

Upgrades

Minor and Patch Upgrades

Read the release notes, back up the database, then let setup migrate before new pods take traffic:

helm repo update zitadel
helm upgrade zitadel zitadel/zitadel -n zitadel --values values.yaml --dry-run
helm upgrade zitadel zitadel/zitadel -n zitadel --values values.yaml --wait

With compose, bump ZITADEL_VERSION in .env, then docker compose pull and docker compose up -d --wait. Add --init-projections=true to setup on large installations to avoid a long catch-up after start.

Upgrade from v3 to v4

  1. Upgrade to the latest v3.4.x (at least v3.4.1, which enables web keys by default).
  2. Wait until tokens signed with legacy keys expire (technical advisory A-10017), or accept a re-login window.
  3. Upgrade to v4 (zitadel setup, then zitadel start). Chart v8 to v9 corresponds to Zitadel v4.
  4. Existing instances stay on Login V1. Newer charts deploy a Login V2 workload by default; set login.enabled: false if you are not adopting it yet.

Migrate CockroachDB to PostgreSQL

v3 and later refuse to start on CockroachDB. Use the mirror command (see the mirror guide for which binary version to run it with):

zitadel init --config new-postgres.yaml
zitadel setup --for-mirror --config new-postgres.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
zitadel mirror --system --config mirror.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
zitadel setup --for-mirror --config new-postgres.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
zitadel mirror verify --system --config mirror.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external

Version Upgrade Compatibility

Path Notes
v4.x to later v4.x Setup then rolling start; zero downtime supported. Skip v4.18.0
v3.x to v4.x Via v3.4.1+ and web keys (A-10017); Login V2 optional
v2.x on CockroachDB to v3+ Mirror to PostgreSQL first
v4.x to next major Not released; roadmap promises migration tooling, production rollout 2027

Backup and Recovery

Database Backup

The database is the only state (plus the masterkey and your config). Back it up before every setup run:

pg_dump -h postgres-host -U zitadel -d zitadel -Fc -f zitadel-$(date +%F).dump
pg_restore -h postgres-host -U zitadel -d zitadel --clean zitadel-2026-09-25.dump

Event Store Recovery

After a restore, start Zitadel with the same masterkey and version (or run setup for a newer one). Projections resume from their recorded positions and replay missing events; a full replay on a large event log can take a long time.

Master Key Handling

The masterkey cannot be rotated in place

Once initialized, changing the masterkey means losing access to every encrypted secret (IdP client secrets, SMTP credentials, signing keys). Store it in a secret manager with the same care as the database backups; a backup without its masterkey is unusable.

API Calls

The examples use a PAT from a service account with the needed administrator role (for example ORG_OWNER).

OIDC Discovery

curl -s https://auth.example.com/.well-known/openid-configuration | jq .

Create a Human User (v2 REST)

curl -X POST https://auth.example.com/v2/users/human \
  -H "Authorization: Bearer ${PAT}" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice@example.com",
    "profile": { "givenName": "Alice", "familyName": "Smith" },
    "email": { "email": "alice@example.com", "sendCode": {} }
  }'

Create a Service Account (Machine User)

CreateUser handles both user types; the /new path avoids a clash with ListUsers:

curl -X POST https://auth.example.com/v2/users/new \
  -H "Authorization: Bearer ${PAT}" \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "'"${ORG_ID}"'",
    "username": "billing-api-sa",
    "machine": {
      "name": "Billing API service account",
      "accessTokenType": "ACCESS_TOKEN_TYPE_JWT"
    }
  }'

Generate a Personal Access Token

curl -X POST https://auth.example.com/v2/users/${USER_ID}/pats \
  -H "Authorization: Bearer ${PAT}" \
  -H "Content-Type: application/json" \
  -d '{ "expirationDate": "2027-01-01T00:00:00Z" }'

Create a Project (Connect RPC)

ProjectService has no REST mapping; call the Connect endpoint with JSON:

curl -X POST https://auth.example.com/zitadel.project.v2.ProjectService/CreateProject \
  -H "Authorization: Bearer ${PAT}" \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "'"${ORG_ID}"'",
    "name": "billing-api",
    "projectRoleAssertion": true
  }'

Create an OIDC Application (Connect RPC)

curl -X POST https://auth.example.com/zitadel.application.v2.ApplicationService/CreateApplication \
  -H "Authorization: Bearer ${PAT}" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "'"${PROJECT_ID}"'",
    "name": "billing-web",
    "oidcConfiguration": {
      "redirectUris": ["https://billing.example.com/auth/callback"],
      "postLogoutRedirectUris": ["https://billing.example.com/"],
      "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
      "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
      "applicationType": "OIDC_APP_TYPE_USER_AGENT",
      "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE"
    }
  }'

OIDC_AUTH_METHOD_TYPE_NONE makes a public client that must use PKCE; use BASIC or PRIVATE_KEY_JWT for confidential web backends.

Token Introspection

Authenticate the API application with Basic auth (or a client_assertion JWT):

curl -X POST https://auth.example.com/oauth/v2/introspect \
  -u "${API_CLIENT_ID}:${API_CLIENT_SECRET}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=${ACCESS_TOKEN}"

Actions V2 Recipes

Add Claims with a Function Execution

Create a target, then bind it to the preaccesstoken (or preuserinfo) function:

curl -X POST https://auth.example.com/v2/actions/targets \
  -H "Authorization: Bearer ${PAT}" -H "Content-Type: application/json" \
  -d '{
    "name": "claims-enricher",
    "restCall": { "interruptOnError": true },
    "endpoint": "https://hooks.example.com/zitadel/claims",
    "timeout": "10s"
  }'

curl -X PUT https://auth.example.com/v2/actions/executions \
  -H "Authorization: Bearer ${PAT}" -H "Content-Type: application/json" \
  -d '{
    "condition": { "function": { "name": "preaccesstoken" } },
    "targets": ["'"${TARGET_ID}"'"]
  }'

Your endpoint receives the function payload and returns the claims to append. Verify the ZITADEL-Signature header with the signing key returned when the target was created.

Migrate from Actions V1

Rewrite each V1 JavaScript flow as an HTTP endpoint and an execution: "complement token" flows become preaccesstoken/preuserinfo function executions, "pre creation" flows become request executions on the user creation method, and "post" flows become response or event executions. V1 and V2 run side by side, so migrate one flow at a time, then delete the V1 action. V1 APIs are scheduled for removal in the next major version.

SDK Integration Patterns

Go: Service Account with JWT Profile

zitadel-go/v3 (from the SDK README):

package main

import (
    "context"
    "log"

    "github.com/zitadel/oidc/v3/pkg/oidc"
    "github.com/zitadel/zitadel-go/v3/pkg/client"
    "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/management"
    "github.com/zitadel/zitadel-go/v3/pkg/zitadel"
)

func main() {
    ctx := context.Background()
    authOption := client.DefaultServiceUserAuthentication(
        "path/to/jwt-key.json",
        oidc.ScopeOpenID,
        client.ScopeZitadelAPI(),
    )
    api, err := client.New(ctx, zitadel.New("auth.example.com"), client.WithAuth(authOption))
    if err != nil {
        log.Fatal(err)
    }
    resp, err := api.ManagementService().GetMyOrg(ctx, &management.GetMyOrgRequest{})
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("org: %s", resp.GetOrg().GetName())
}

client.PasswordAuthentication(clientID, clientSecret, ...) switches to client credentials. For protecting your own Go web app, use the SDK's authentication helpers (built on zitadel/oidc).

Python: Service Account

zitadel-client (PyPI, 4.1.9, marked incubating; for service accounts only, not end-user login):

import zitadel_client as zitadel
from zitadel_client.models import (
    UserServiceAddHumanUserRequest,
    UserServiceSetHumanEmail,
    UserServiceSetHumanProfile,
)

client = zitadel.Zitadel.with_private_key("https://auth.example.com", "path/to/jwt-key.json")
request = UserServiceAddHumanUserRequest(
    username="john.doe",
    profile=UserServiceSetHumanProfile(givenName="John", familyName="Doe"),
    email=UserServiceSetHumanEmail(email="john@example.com"),
)
print(client.users.add_human_user(request))

React: OIDC Login

@zitadel/react wraps oidc-client-ts and defaults to code flow with PKCE:

import { createZitadelAuth } from "@zitadel/react";

const zitadel = createZitadelAuth({
  authority: "https://auth.example.com",
  client_id: "BILLING_WEB_CLIENT_ID",
  project_resource_id: "BILLING_PROJECT_ID", // adds the project role scopes
});

export function LoginButton() {
  return <button onClick={() => zitadel.authorize()}>Login</button>;
}

zitadel.signout() logs out, and zitadel.userManager exposes the underlying oidc-client-ts UserManager (for example getUser() to read claims). The 1.1.1 type definitions export only authorize, signout, and userManager, although the README also mentions a role helper. For other frameworks use any certified OIDC client library.

Terraform

Provider Configuration

terraform {
  required_providers {
    zitadel = {
      source  = "zitadel/zitadel"
      version = "~> 3.8"
    }
  }
}

provider "zitadel" {
  domain           = "auth.example.com"
  port             = "443"
  insecure         = false
  jwt_profile_file = "zitadel-admin-sa.json"   # or access_token = var.zitadel_pat
}

Create Organization, Project, Roles, and Application

resource "zitadel_org" "billing" {
  name = "Billing Team"
}

resource "zitadel_project" "billing_api" {
  name                   = "billing-api"
  org_id                 = zitadel_org.billing.id
  project_role_assertion = true
  project_role_check     = true
}

resource "zitadel_project_role" "admin" {
  project_id   = zitadel_project.billing_api.id
  org_id       = zitadel_org.billing.id
  role_key     = "admin"
  display_name = "Administrator"
  group        = "billing"
}

resource "zitadel_project_role" "viewer" {
  project_id   = zitadel_project.billing_api.id
  org_id       = zitadel_org.billing.id
  role_key     = "viewer"
  display_name = "Viewer"
  group        = "billing"
}

resource "zitadel_application_oidc" "web" {
  project_id = zitadel_project.billing_api.id
  org_id     = zitadel_org.billing.id
  name       = "billing-web"

  redirect_uris             = ["https://billing.example.com/auth/callback"]
  post_logout_redirect_uris = ["https://billing.example.com/"]
  response_types            = ["OIDC_RESPONSE_TYPE_CODE"]
  grant_types               = ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"]
  app_type                  = "OIDC_APP_TYPE_USER_AGENT"
  auth_method_type          = "OIDC_AUTH_METHOD_TYPE_NONE"
}

Kubernetes Operations

kubectl -n zitadel port-forward svc/zitadel 8080:8080              # API and Console
kubectl -n zitadel logs job/zitadel-setup                           # migration output
kubectl -n zitadel exec deploy/zitadel -- /app/zitadel ready         # readiness from inside the pod
helm -n zitadel status zitadel
helm -n zitadel uninstall zitadel                                    # the database and secrets are not removed

Common Issues

Issue Cause Resolution
"Instance not found" ExternalDomain/ExternalPort/ExternalSecure do not match the URL, or were changed without rerunning setup Fix the values and run zitadel setup again
Console loads but API calls fail Proxy does not forward HTTP/2 (h2c) Use grpc_pass (NGINX), h2c scheme (Traefik), or GRPC backend protocol on ingress-nginx
Login V2 shows errors or 401 Login container lacks a valid IAM_LOGIN_CLIENT PAT, or wrong ZITADEL_API_URL/Host header Check the PAT file path and CUSTOM_REQUEST_HEADERS Host value
relation "eventstore.events" does not exist Database provisioned manually without schema bootstrap Run zitadel init schema
Initial setup fails with password complexity error ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD too weak Use 8+ chars with upper, lower, digit, symbol
JWTs rejected right after a v4 upgrade Web keys were not active on v3 (A-10017) Users re-login; next time upgrade via v3.4.1+ and soak
Setup job fails on v4.18.0 Known broken setup step Use v4.19.0 or later
Slow reads after an upgrade Projections catching up Use --init-projections=true in setup; check failed_events
MFA prompt loops Organization login policy overrides the instance policy Check which policy applies to the user's organization

Sources