Skip to content

Zitadel Production Security

John R. D'Orazio edited this page Apr 2, 2026 · 4 revisions

Zitadel Production Security

This page covers the security features and configuration required for production deployments.

Security Checklist

Before going live, ensure all of the following are addressed:

  • APP_ENV=production is set
  • Zitadel master key is a unique 32-character random string (not the default placeholder)
  • All PostgreSQL passwords are unique and strong (postgres, zitadel, litcal users)
  • Zitadel default admin password has been changed after first login
  • JWT_SECRET is a unique, randomly generated 64+ character hex string
  • ADMIN_PASSWORD_HASH is set with a strong Argon2id hash
  • ALLOW_ENV_ADMIN_FALLBACK=false in production
  • CORS_ALLOWED_ORIGINS lists specific origins (not *)
  • HTTPS is configured (either directly or via reverse proxy)
  • HTTPS_ENFORCEMENT=true
  • X-Forwarded-Proto header is set by the reverse proxy
  • Rate limit storage path is persistent (not /tmp)
  • Adminer is removed or restricted in production
  • Database backups are configured
  • Authentication logs are being monitored

Rate Limiting

How It Works

IP-based rate limiting protects the /auth/login endpoint against brute-force attacks:

  • Failed login attempts are tracked per IP address
  • After exceeding the limit, further attempts are blocked with HTTP 429
  • The Retry-After header indicates when the client can retry
  • Successful login clears the rate limit for that IP

Configuration

RATE_LIMIT_LOGIN_ATTEMPTS=5        # Max failed attempts (default: 5)
RATE_LIMIT_LOGIN_WINDOW=900        # Window in seconds (default: 900 = 15 minutes)
RATE_LIMIT_STORAGE_PATH=/var/lib/litcal/rate_limits  # Persistent storage path

Rate Limited Response

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 847

{
    "type": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
    "title": "Too Many Requests",
    "status": 429,
    "detail": "Too many login attempts. Please try again later.",
    "retryAfter": 847
}

HTTPS Enforcement

In staging and production environments, the API requires HTTPS for all authentication and admin endpoints (/auth/*, /admin/*, /applications/*). Non-HTTPS requests receive HTTP 403.

HTTPS Detection

The API detects HTTPS via (in order):

  1. $_SERVER['REQUEST_SCHEME'] === 'https'
  2. $_SERVER['HTTPS'] === 'on'
  3. $_SERVER['SERVER_PORT'] === '443'
  4. $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' (from reverse proxy)

When TLS terminates at the reverse proxy, the X-Forwarded-Proto header must be set. See Production Deployment for proxy configuration.

Disabling

HTTPS_ENFORCEMENT=false

Only disable this if HTTPS is handled externally and you understand the implications.

JWT Secret Validation

Requirements

  • Minimum 32 characters
  • Must not contain placeholder patterns in staging/production

Rejected Placeholder Patterns

In staging and production, the API rejects secrets containing:

change-this, change-me, replace-this, replace-me, your-secret, my-secret, secret-key, example, placeholder, default, insecure, password, test-secret, dev-secret, xxxxxxxx

Generating a Secure Secret

php -r "echo bin2hex(random_bytes(32));"
# or
openssl rand -hex 32

Cookie Security

Authentication tokens are stored in HttpOnly cookies:

Cookie Path SameSite Secure Purpose
litcal_access_token / Lax Over HTTPS Access token for API requests
litcal_refresh_token /auth Strict Over HTTPS Refresh token (auth endpoints only)

These cookies:

  • Cannot be accessed by JavaScript (XSS protection)
  • Are automatically sent with requests
  • Include the Secure flag when served over HTTPS

CORS Configuration

For cross-origin requests with cookies, wildcard origins (*) are not supported. Specify allowed origins explicitly:

CORS_ALLOWED_ORIGINS=https://litcal.johnromanodorazio.com,https://www.liturgicalcalendar.com

Auth endpoint error responses only reflect validated origins as a security measure.

Admin Credentials

Environment-Based Behavior

Environment Behavior
development / test Falls back to default password "password" if hash is invalid/missing
staging / production Requires valid ADMIN_PASSWORD_HASH (throws exception if missing)

Generating a Password Hash

php -r "echo password_hash('your-secure-password', PASSWORD_ARGON2ID);"

Monitoring and Logging

Log Files

Authentication events are logged to:

  • logs/auth-YYYY-MM-DD.log (plain text)
  • logs/auth.json-YYYY-MM-DD.log (JSON format)

Events Logged

Level Event
INFO Successful logins
INFO Logouts
WARNING Failed login attempts
WARNING Rate-limited requests

Example Log Entries

[2025-12-06 10:30:00] INFO: Login successful
    username: admin
    client_ip: 192.168.1.100

[2025-12-06 10:35:00] WARNING: Login failed
    username: attacker
    client_ip: 192.168.1.50
    reason: Invalid credentials
    remaining_attempts: 2

[2025-12-06 10:40:00] WARNING: Login rate limited
    client_ip: 192.168.1.50
    retry_after: 600

Incident Response

Signs of a Brute-Force Attack

  • Multiple failed login attempts from the same IP
  • Rate-limited requests appearing in logs
  • Attempts from multiple IPs targeting the same account

Response Steps

  1. Review auth logs for attack patterns
  2. Consider temporarily blocking offending IPs at the firewall level
  3. If an account may be compromised, rotate the ADMIN_PASSWORD_HASH
  4. Rotate JWT_SECRET to invalidate all existing tokens
  5. Review and potentially increase rate limit thresholds

Clone this wiki locally