Master Key Management
The master key is a 32-byte AES-256 secret that encrypts every secret in the Zitadel DB (client secrets, TOTP seeds, IDP keys, SMTP passwords). Lose it and a DB restore is worthless. No tooling can recover it.
Rotation in-place is not supported in v4 (tracked in upstream issue #6768, targeted at v5). Until v5 ships the rotate flag, the only rotation is the "reinstall fresh, migrate users" path — see bottom of this file.
1. Storage
Do not put the key in docker-compose.yml, .env, or git. Do not pass via -e ZITADEL_MASTERKEY in systemd unit files (shows in ps).
Store on disk, mount read-only into the container:
# One-time, as root:
install -d -m 0700 -o root -g root /etc/zitadel
openssl rand -base64 32 | head -c 32 > /etc/zitadel/masterkey
chmod 0400 /etc/zitadel/masterkey
chown root:root /etc/zitadel/masterkey
Compose mount (read-only):
services:
zitadel:
command: start-from-init --masterkeyFile /run/secrets/masterkey --tlsMode external
volumes:
- /etc/zitadel/masterkey:/run/secrets/masterkey:ro
Verify after boot:
docker exec zitadel ls -l /run/secrets/masterkey # must be -r-------- root
docker exec zitadel stat -c '%a %U' /run/secrets/masterkey # "400 root"
2. Backup (3-2-1, separate custody from DB)
Rule: the master key and the DB backup must not sit in the same bucket with the same credentials. If one key compromises both, you have a 1-2 backup.
- Copy 1 (local, hot):
/etc/zitadel/masterkeyon host — already here. - Copy 2 (local, cold): encrypted on a second disk / NAS:
- Copy 3 (offsite, paper): print the base64 of the key, seal in tamper-evident envelope, store in physical safe. Rotate envelope on every key regeneration.
Custody separation: DB backups go to offsite:zitadel-db, master key copies go to offsite:zitadel-key with a different rclone remote using a different API key. Never to the same bucket.
3. Tooling choice (50-user scale)
| Tool | Verdict |
|---|---|
age + filesystem |
Recommended. Simple, auditable, works for single host. |
pass (pass-store + gpg) |
Fine if team already uses it. |
| Bitwarden CLI | OK as the paper-envelope offsite copy, not the hot path. |
| HashiCorp Vault | Overkill for 50 users; adds its own unseal-key problem. |
| AWS Secrets Manager / GCP Secret Manager | Good if already on that cloud; otherwise one more vendor. |
Default for HUPH: age on disk + base64 in physical safe.
4. Recovery drill (quarterly, 30 min)
Schedule: first Monday of Jan/Apr/Jul/Oct, 10am. Logged in docs/ops/zitadel/drill-log.md.
- Spin a scratch VM or container.
- Restore the DB backup from 7-day-old daily.
- Restore the master key from offsite copy (not the hot copy on prod host — that defeats the drill).
docker compose upwith scratch compose.curl http://localhost:8080/debug/ready→ok.- Login as break-glass admin.
- Write date, who ran it, pass/fail in
drill-log.md. Tear down.
If the drill fails, open a P1 incident. A non-recoverable backup is worse than no backup because it hides the risk.
5. Rotation (v4: workaround; v5: use --masterkey-old when available)
v4 has no live rotation. If the key is compromised:
- Declare incident, freeze writes:
docker compose stop zitadel(keep DB up). - Stand up parallel fresh Zitadel instance with a new masterkey and empty DB.
- Export users via management API from old instance (re-encrypt on export).
- Re-provision applications / OIDC clients manually (client secrets are unrecoverable).
- Invalidate all sessions: every user re-enrolls MFA.
- Swap DNS
auth.huph.val.idto new instance. Retire old.
Expected cost: 4-8 hours + user re-enrollment comms. Treat the key accordingly.
When v5 ships: upgrade, then zitadel start --masterkey <new> --masterkey-old <old> re-encrypts on read. Test in staging first. Update this doc when the flag goes GA.