Identity and access

Staff and customers in separate Zitadel organizations, the secure way

Pull up a chair. Your customer's administrator just reset the password of one of your instance owners, and Zitadel said OK, because you had put that account in the customer's organization.

The short answer

Create one organization for instance administrators and automation only. Put every account with an instance role (`IAM_*`) there, with its own strict login settings. Customer organizations hold only their own users and administrators with `ORG_*` roles. Audit regularly for instance administrators whose account lives in any other organization.

Updated Houssam Hammoudi, CTOTested with Zitadel v4.19.1, PostgreSQL 18

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A Zitadel instance holds many organizations. Staff and customers look alike as users, so the first staff accounts are often created wherever the console happened to be open, including in a customer's organization.

An organization owner (ORG_OWNER) manages the users of that organization: passwords, MFA, login settings. That is the point of the role. If one of those users is also an instance owner (IAM_OWNER), the customer's administrator now controls the credentials of someone who can change the whole instance.

The same organization's login settings apply to that account. The customer's administrator can turn off MFA or allow self-registration there, and your instance owner inherits it.

Nobody notices, because the console shows both kinds of user in one list.

What the docs say

When both kinds of principals share the same organization, their authorization scope and login experience overlap in ways that can potentially lead to unexpected account takeover.

Source: Zitadel docs, Hardening instance administrators

Organization Administrators may change MFA, passkeys, external IdPs, or self-registration for the organization where instance-administrator accounts live.

Source: Zitadel docs, Hardening instance administrators

The default settings can be overridden for each organization, some policies are currently only available on the instance level.

Source: Zitadel docs, Settings and policies

The docs describe the risk and the layout, and suggest a periodic review with the ListAdministrators API, whose response includes each administrator's organization. They leave the review as a list of questions. The audit below turns it into a script that fails loudly.

The secure configuration

The layout:

text
Instance
├── Staff                         instance administrators and automation only
│   ├── ops-alice                 IAM_OWNER, human, passkey
│   ├── break-glass               IAM_OWNER, service account, PAT stored offline
│   └── login-client              IAM_LOGIN_CLIENT only
├── Acme                          a customer
│   └── end users, Acme's own admins with ORG_* roles
└── Globex                        another customer
    └── end users, Globex's own admins with ORG_* roles

Rules that keep it that way:

  • Create instance administrators only in the Staff organization.
  • Grant IAM_* roles only to users whose organization is Staff.
  • Give customers ORG_* roles in their own organization only. Never grant them roles in Staff.
  • Give Staff its own login settings (MFA forced, no self-registration, no external IdPs you do not control). Do not let Staff inherit whatever the instance default becomes.
  • Do not add customer users or customer administrators to Staff.

Create a customer organization and its first administrator through the API, from automation that runs as a Staff service account:

bash
ZT=https://id.example.com
# The org
ACME=$(curl -s -X POST $ZT/v2/organizations -H "Authorization: Bearer $STAFF_PAT" \
  -H 'Content-Type: application/json' -d '{"name":"Acme"}' | jq -r .organizationId)
# Its administrator: a user that already lives in Acme, given ORG_OWNER in Acme only
curl -s -X POST $ZT/management/v1/orgs/me/members -H "Authorization: Bearer $STAFF_PAT" \
  -H "x-zitadel-orgid: $ACME" -H 'Content-Type: application/json' \
  -d "{\"userId\":\"$ACME_ADMIN_ID\",\"roles\":[\"ORG_OWNER\"]}"

The two v1 member endpoints used here (/management/v1/orgs/me/members and /admin/v1/members/_search) still work in Zitadel v4.19.1 but are marked deprecated. The replacement is the Internal Permission Service v2 (CreateAdministrator, ListAdministrators); move the scripts to it when you upgrade.

The audit. Run it on a schedule with a read-only instance account (IAM_OWNER_VIEWER) and alert on any output:

bash
#!/bin/sh
# Instance administrators whose account lives outside the Staff organization.
set -eu
: "${ZT:?}" "${PAT:?}" "${STAFF:?}"
api() { curl -s -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' "$@"; }
for id in $(api -X POST "$ZT/admin/v1/members/_search" -d '{}' | jq -r '.result[].userId'); do
  api "$ZT/v2/users/$id" | jq -r --arg staff "$STAFF" \
    'select(.user.details.resourceOwner != $staff) | "OUTSIDE STAFF: \(.user.username) (org \(.user.details.resourceOwner))"'
done

Prove it

The test in secure-tests/staff-customers-separate-zitadel-organisations/ runs Zitadel v4.19.1. It creates a customer organization, Acme, with its own administrator (a service account with ORG_OWNER in Acme). Then it makes the mistake: ops-bob is created in Acme and made an instance owner. ops-alice is created in Staff and made an instance owner too.

bash
curl -X POST $ZT/admin/v1/members/_search -d '{}' | jq -r '.result[] | select(.roles|index("IAM_OWNER")) | "\(.preferredLoginName)\t\(.roles|join(","))"'
text
bootstrap	IAM_OWNER
ops-alice	IAM_OWNER
ops-bob	IAM_OWNER
[email protected]	IAM_OWNER

Acme's administrator sets a new password for ops-bob. The password works:

bash
curl -X POST $ZT/v2/users/<ops-bob>/password -H "Authorization: Bearer <acme-admin PAT>" \
  -d '{"newPassword":{"password":"Attacker-Chosen-Pw-9!","changeRequired":false}}'
curl -X POST $ZT/v2/sessions -d '{"checks":{"user":{"userId":"<ops-bob>"},"password":{"password":"Attacker-Chosen-Pw-9!"}}}'
text
OK
{"sessionId":"392232...","passwordChecked":true}

The same request against ops-alice in Staff, then two more reaches outside Acme:

text
error 5: membership not found (AUTHZ-cdgFk)
error 5: membership not found (AUTHZ-cdgFk)
error 7: No matching permissions found (AUTH-5mWD2)

Those are: reset ops-alice's password, list the users in Staff, and read the instance login settings. Inside Acme, Acme's administrator is in charge, as it should be:

text
acme-admin, ops-bob

The audit finds the misplaced account:

text
OUTSIDE STAFF: ops-bob (org 392232971257511939)

Mistakes people make

Using the first organization for everyone

The organization created at setup is convenient. If it also holds customers or their administrators, it is not a staff organization any more. Keep it for staff, or create a new one for staff.

Granting an instance role to a customer-org account

It works, and that is the trap. The account's password, MFA and login rules stay under the customer organization's control.

Letting Staff inherit the instance defaults

Instance defaults change for customer reasons: a customer wants self-sign-up, someone relaxes a lifetime. Give Staff its own explicit login settings.

Sharing one service account between orgs

A PAT with instance rights used by a customer-facing integration is a staff credential in a customer's hands. One service account per job, in the organization that owns the job.

Assuming the default admin is gone

Zitadel's first instance setup can create a human admin as well as the service account you asked for. The test shows [email protected] next to bootstrap. Remove or secure it (see Zitadel on Kubernetes).

Checklist

  • Create one organization for instance administrators and automation only.
  • Create every account that will hold an IAM_* role in that organization.
  • Give that organization its own login settings with MFA forced and registration off.
  • Keep customer users and customer administrators out of it.
  • Grant customers ORG_* roles in their own organization only.
  • Run the audit script on a schedule and alert on any OUTSIDE STAFF line.
  • Review the instance administrator list monthly and remove unknown accounts.

Organizations in Zitadel are a strong wall. It only protects your staff if they are standing on the right side of it.

H2-CIAE

Learn it on a live range

SSO and lifecycle, in Identity and Access Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on identity and access

Self-hosted identity with Zitadel, private access with Headscale, break-glass and offboarding.

All identity and access guides