Forcing MFA in Zitadel, the secure way
You ticked "Force MFA" at the instance level and went home happy. Then a customer's admin created their own login policy with MFA off, and the instance setting stopped applying to every user in that organization.
The short answer
Set the instance login policy to force MFA for all users, not only local ones, and turn off self-registration. Give the staff organization its own explicit policy. Run a scheduled audit of every organization's effective login policy, because an organization owner can replace the instance default with a custom policy of their own.
On this page
What goes wrong
A fresh Zitadel instance does not force MFA and allows self-registration. The test below prints the defaults.
"Force MFA for local authenticated users" is the option people pick when they also use Google or Microsoft sign-in. Users who come through that IdP then need no second factor from Zitadel, and nobody checks whether the IdP asked for one.
Login settings are per organization. The instance setting is only a default. An organization with its own policy ignores the default completely, and an organization owner is allowed to create one.
What the docs say
Force a user to register and use a multifactor authentication, by checking the option "Force MFA".
Source: Zitadel docs, Default settings: multifactor
Or you can enable the "Force MFA for local authenticated users", which will enforce this rule only on local authentication, but not on users authenticated through an Identity Provider.
Source: Zitadel docs, Default settings: multifactor
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 say organizations can override the default. The settings pages do
not say that an organization's own administrator can do it (the test below
shows an ORG_OWNER can), or offer a way to see which organizations have. The
audit script below does.
The secure configuration
The instance login policy, applied with an instance administrator's token:
{
"allowUsernamePassword": true,
"allowRegister": false,
"allowExternalIdp": true,
"forceMfa": true,
"forceMfaLocalOnly": false,
"passwordlessType": "PASSWORDLESS_TYPE_ALLOWED",
"hidePasswordReset": false,
"ignoreUnknownUsernames": true,
"allowDomainDiscovery": false,
"passwordCheckLifetime": "864000s",
"externalLoginCheckLifetime": "864000s",
"mfaInitSkipLifetime": "2592000s",
"secondFactorCheckLifetime": "64800s",
"multiFactorCheckLifetime": "43200s"
}curl -s -X PUT "$ZT/admin/v1/policies/login" -H "Authorization: Bearer $PAT" \
-H 'Content-Type: application/json' -d @instance-login-policy.jsonWhat the security lines do:
forceMfa: truewithforceMfaLocalOnly: false: every user needs a second factor, whether they typed a password or came through an external IdP.allowRegister: false: nobody creates an account by visiting the login page.ignoreUnknownUsernames: true: the login page does not reveal which usernames exist.- Second factors stay TOTP and U2F, the defaults of a fresh instance (see the test). This update does not change the factor lists; they have their own endpoints. Do not add OTP by SMS or email for staff.
The staff organization gets its own explicit policy (MFA forced, no
registration, no external IdPs you do not control, shorter check lifetimes),
so a later change to the instance default cannot weaken it. A custom policy
gets only the factors listed in the request (secondFactors and
multiFactors in the management API proto), so list them:
curl -s -X POST "$ZT/management/v1/policies/login" -H "Authorization: Bearer $PAT" \
-H "x-zitadel-orgid: $STAFF_ORG_ID" -H 'Content-Type: application/json' \
-d '{"allowUsernamePassword":true,"allowRegister":false,"allowExternalIdp":false,
"forceMfa":true,"forceMfaLocalOnly":false,"passwordlessType":"PASSWORDLESS_TYPE_ALLOWED",
"ignoreUnknownUsernames":true,"passwordCheckLifetime":"86400s",
"externalLoginCheckLifetime":"86400s","mfaInitSkipLifetime":"2592000s",
"secondFactorCheckLifetime":"28800s","multiFactorCheckLifetime":"28800s",
"secondFactors":["SECOND_FACTOR_TYPE_OTP","SECOND_FACTOR_TYPE_U2F"],
"multiFactors":["MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION"]}'The audit. Run it on a schedule with an IAM_OWNER_VIEWER service account; a
non-zero exit is an alert:
#!/bin/sh
# mfa-audit.sh: print every organization's effective login policy; exit 1 if
# any organization does not force MFA for all users (local and external).
# Needs ZT (base URL) and PAT (a token with IAM_OWNER_VIEWER or IAM_OWNER).
set -eu
api() { curl -s -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' "$@"; }
bad=0
for org in $(api -X POST "$ZT/v2/organizations/_search" -d '{}' | jq -r '.result[] | "\(.id)=\(.name)"'); do
id=${org%%=*}; name=${org#*=}
line=$(api "$ZT/management/v1/policies/login" -H "x-zitadel-orgid: $id" | jq -r \
'.policy | "\(if .isDefault then "inherits instance" else "custom policy " end) forceMfa=\(.forceMfa // false) localOnly=\(.forceMfaLocalOnly // false) register=\(.allowRegister // false)"')
printf '%-8s %s\n' "$name" "$line"
echo "$line" | grep -q 'forceMfa=true localOnly=false' || bad=1
done
exit $badWhen the audit fails, find out who changed the policy (the event
org.policy.login.added or org.policy.login.changed in the Event API shows
the editor), talk to the organization, then reset it to the instance default:
curl -s -X DELETE "$ZT/management/v1/policies/login" -H "Authorization: Bearer $PAT" \
-H "x-zitadel-orgid: $ORG_ID"Prove it
From secure-tests/forcing-mfa-zitadel/, against Zitadel v4.19.1. The
defaults of a fresh instance:
curl $ZT/admin/v1/policies/login | jq -c '.policy | {forceMfa, forceMfaLocalOnly, allowRegister, mfaInitSkipLifetime, secondFactors, multiFactors}'{"forceMfa":false,"forceMfaLocalOnly":false,"allowRegister":true,"mfaInitSkipLifetime":"2592000s","secondFactors":["SECOND_FACTOR_TYPE_OTP","SECOND_FACTOR_TYPE_U2F"],"multiFactors":["MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION"]}After the instance policy is applied, both organizations inherit it:
./mfa-audit.sh; echo "exit $?"Staff inherits instance forceMfa=true localOnly=false register=false
Acme inherits instance forceMfa=true localOnly=false register=false
exit 0Acme's own administrator (ORG_OWNER in Acme, nothing else) creates a custom
login policy with MFA off and registration on. Zitadel accepts it, and the
audit catches it:
OK
Staff inherits instance forceMfa=true localOnly=false register=false
Acme custom policy forceMfa=false localOnly=false register=true
exit 1Staff gets its explicit policy, and Acme is reset to the instance default:
Staff custom policy forceMfa=true localOnly=false register=false
Acme inherits instance forceMfa=true localOnly=false register=false
exit 0One more thing the test shows. The Session API creates a session for a Staff user with only the password checked, although Staff forces MFA:
curl $ZT/v2/sessions/<id> | jq -c '.session.factors | {user: (.user != null), password: (.password != null), totp: (.totp != null), webAuthN: (.webAuthN != null)}'{"user":true,"password":true,"totp":false,"webAuthN":false}A session is a record of the factors that were checked. The login UI decides whether they are enough. If you build your own login on the Session API, read the login settings and require the second factor yourself.
Mistakes people make
forceMfaLocalOnly for mixed setups
It exempts everyone who signs in through an external IdP. Unless you control that IdP and require MFA there, set it to false.
Trusting the instance setting to bind every organization
An organization with a custom policy does not inherit it. Organization owners can create one. Audit every organization, not the instance.
Leaving registration on
A fresh instance allows self-registration. On a staff-facing instance, that is an open door with a sign-up form.
SMS as the second factor for admins
OTP by SMS can be redirected by SIM swapping. Staff use passkeys or TOTP.
A custom login UI that checks only the password
The Session API records factors; it does not refuse a login. If you replace the login UI, enforce the login policy in your code and test it with a user who has no second factor.
No lockout path
Forcing MFA on the organization that holds every administrator can lock you out if the MFA provider breaks. Keep a break-glass service account with a PAT stored offline (see break-glass accounts).
Checklist
- Set
forceMfa: trueandforceMfaLocalOnly: falseon the instance login policy. - Set
allowRegister: falseunless public sign-up is a product feature. - Set
ignoreUnknownUsernames: true. - Give the staff organization its own explicit login policy with MFA forced.
- Allow TOTP and U2F; do not allow SMS or email OTP for staff.
- Run
mfa-audit.shon a schedule and alert on a non-zero exit. - Reset rogue organization policies with
DELETE /management/v1/policies/loginafter talking to the owner. - Test any custom login UI with a user who has no second factor.
- Keep a break-glass service account outside interactive login.
"Force MFA" is a default, not a law. Check every organization, every day, and it becomes one.
H2-CIAE
Learn it on a live range
Passkeys and step-up, 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