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.
On this page
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:
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_* rolesRules 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:
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:
#!/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))"'
doneProve 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.
curl -X POST $ZT/admin/v1/members/_search -d '{}' | jq -r '.result[] | select(.roles|index("IAM_OWNER")) | "\(.preferredLoginName)\t\(.roles|join(","))"'bootstrap IAM_OWNER
ops-alice IAM_OWNER
ops-bob IAM_OWNER
[email protected] IAM_OWNERAcme's administrator sets a new password for ops-bob. The password works:
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!"}}}'OK
{"sessionId":"392232...","passwordChecked":true}The same request against ops-alice in Staff, then two more reaches outside
Acme:
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:
acme-admin, ops-bobThe audit finds the misplaced account:
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 STAFFline. - 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 freeThe 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