Identity and access

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.

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 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:

json
{
  "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"
}
bash
curl -s -X PUT "$ZT/admin/v1/policies/login" -H "Authorization: Bearer $PAT" \
  -H 'Content-Type: application/json' -d @instance-login-policy.json

What the security lines do:

  • forceMfa: true with forceMfaLocalOnly: 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:

bash
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:

bash
#!/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 $bad

When 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:

bash
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:

bash
curl $ZT/admin/v1/policies/login | jq -c '.policy | {forceMfa, forceMfaLocalOnly, allowRegister, mfaInitSkipLifetime, secondFactors, multiFactors}'
text
{"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:

bash
./mfa-audit.sh; echo "exit $?"
text
Staff    inherits instance  forceMfa=true  localOnly=false  register=false
Acme     inherits instance  forceMfa=true  localOnly=false  register=false
exit 0

Acme'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:

text
OK
Staff    inherits instance  forceMfa=true  localOnly=false  register=false
Acme     custom policy      forceMfa=false  localOnly=false  register=true
exit 1

Staff gets its explicit policy, and Acme is reset to the instance default:

text
Staff    custom policy      forceMfa=true  localOnly=false  register=false
Acme     inherits instance  forceMfa=true  localOnly=false  register=false
exit 0

One more thing the test shows. The Session API creates a session for a Staff user with only the password checked, although Staff forces MFA:

bash
curl $ZT/v2/sessions/<id> | jq -c '.session.factors | {user: (.user != null), password: (.password != null), totp: (.totp != null), webAuthN: (.webAuthN != null)}'
text
{"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: true and forceMfaLocalOnly: false on the instance login policy.
  • Set allowRegister: false unless 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.sh on a schedule and alert on a non-zero exit.
  • Reset rogue organization policies with DELETE /management/v1/policies/login after 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 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