Security Model
This page is the single overview of how Citadel handles authentication, authorization, and abuse-resistance — useful for operators sizing up the product, pen-testers writing a checklist, and anyone touching a security-relevant doc.
For what each piece does in detail, follow the cross-references; this page is the map.
At a glance
Browser ─┬─ HttpOnly auth-token cookie ─┐
├─ JS-readable csrf-token cookie ┤
└─ X-CSRF-Token header on POST ─┤
Desktop ── Authorization: Bearer JWT ────┼─→ /api/* on the controller
Sidecar ── Authorization: Bearer (its key) → :9100 on the sidecar
Discord ── HMAC-signed call + bot API key → /api/discord/action
Cloud ── Bearer license JWT (RS256) → api.citadels.cc /api/v1/*
Authentication
Browser sessions: cookie + CSRF
POST /api/auth/login issues two cookies:
auth-token— HttpOnly,SameSite=Lax,Secureover HTTPS. JavaScript can't read it. The middleware uses this as the session.csrf-token— readable by JavaScript. Echo it back asX-CSRF-Tokenon everyPOST/PUT/PATCH/DELETE. Mismatches return403 csrf-mismatch.
Browser fetches need credentials: 'include'. CSRF-exempt paths (login, logout, setup, health, store webhook, discord) skip the header check — see the REST API reference → Authentication for the full list.
Desktop / scripted clients: Bearer
The same login response includes a JWT. Pass it as Authorization: Bearer <jwt>. The middleware reads cookie first and falls back to Bearer, so a single login can drive either flow.
Sign-out
POST /api/auth/logout clears the auth-token cookie and revokes the JWT id (so an attacker who captured the JWT before logout can't replay it). Exempt from CSRF.
Multi-factor (TOTP)
Enable from Settings → Security → Two-Factor Authentication on either side (controller or citadels.cc account). After enrollment, sign-in needs both your password and a 6-digit code (or a one-time backup code).
The TOTP secret is encrypted at rest using ENCRYPTION_KEY. Backup codes are bcrypt-hashed individually — a leaked DB snapshot can't be used to forge recovery. Failed codes count toward the same fail2ban counter as failed passwords.
See: Two-Factor Authentication guide.
Cloud licensing: RS256 JWT
The desktop app signs in to api.citadels.cc once (email + password) and gets a short-lived RS256 JWT carrying subscriptionStatus, cloudSubscriptionStatus, entitlements: ['citadel'] | ['citadel', 'cloud'], and a per-device id. The desktop validates the JWT locally with an embedded public key — that's how offline-grace works. Tokens with the cloud entitlement get a tighter 4h lifetime so a cancellation fades fast.
See: Citadel Cloud → License token and entitlements.
Authorization
Citadel role model
Roles map to a per-permission grant set. Permissions are granular — server.start, server.restart, server.deploy, players.kick, players.ban, users.manage, files.edit, files.edit-scripts, chat.send, etc. Manage from Settings → Users & Roles.
Three built-in role tiers for the admin dashboard hierarchy:
| Role | Scope |
|---|---|
support | Customer lookup, resend welcome, view webhooks |
finance | Everything support can + full subscription/transaction details |
admin | Full control: edit users, force Paddle sync, replay webhooks, grant admin roles |
Higher roles include lower ones: admin > finance > support. The legacy is_super_admin boolean is kept for backward compatibility and reconciled to admin_role = 'admin' at boot.
files.edit-scripts is intentionally separate
Editing config files (serverDZ.cfg, mod configs, JSON profiles) requires the standard files.edit permission. Editing executables (.bat / .cmd / .ps1 / .sh) requires the additional files.edit-scripts permission AND the destination must resolve under <installDir>/lifecycle_hooks/. Otherwise the write is rejected with file.write-blocked in the audit log.
The split exists because anyone who can save a .ps1 to disk can effectively run code on the server box. None of the built-in roles include files.edit-scripts by default — you grant it explicitly to the small group of people you trust with shell, not just with config.
See: Lifecycle Hooks → Editing scripts from the dashboard.
Discord bot: 3-layer security
The bot is the highest-traffic privileged caller of the API, so its auth contract is layered:
- Citadel role permissions. Every
/api/discord/actioncall is gated against the built-indiscord-botrole. Defaults to*so existing deployments work; tighten to e.g.server.view + server.restart + players.kick + chat.sendto bound the bot. - Verified attribution. Each call is HMAC-SHA256-signed over
(timestamp, action, discordUserId)viaX-Discord-Ts+X-Discord-Sig. Mismatches → 403 withdiscord.sig-rejected. Stealing the API key alone no longer lets you forge attributed actions. - Per-Discord-user role mapping.
data/discord-user-roles.jsonlets you grant or restrict individual Discord users above/below the discord-bot floor. CRUD endpoints documented in the REST API reference → Integrations.
See: Discord Bot → Security.
Abuse resistance
fail2ban + per-(IP, username) lockout
Failed sign-ins, license activations, 2FA challenges, and reset-token consumption all funnel into a Redis-backed counter keyed by (IP, username). Five failures within 10 minutes trips an escalating ban: 60s → 5 min → 1 hour.
Two-track design: a parallel per-IP counter catches credential-stuffing across many usernames from the same address, while the per-(IP, username) counter prevents an attacker who knows your username from locking you out from arbitrary IPs.
If Redis is unreachable, the counters fail-open — preference is to let real users in over hard-blocking on a dependency outage.
Rate limits
Per-IP scoped caps sit on top of the lockout system:
/api/*— 600/min global ceiling./api/auth/*— 15 / 15 min./api/discord/*— 60/min./api/maps/tiles/*— exempt (browser-cached).
A 429 carries Retry-After in seconds. Cloud Bans submission also enforces a per-customer cap inside the reputation engine (50/24h, 1000/30d by default).
Setup wizard one-shot
data/.first-run-completed survives setup_complete.json deletion. Once the wizard ran once, every /api/setup/* returns 403. Closes the "delete-the-flag-file → re-arm-the-wizard → take-over-admin" attack path. Re-arming legitimately needs support intervention — see Re-running the wizard.
Setup wizard re-auth on sensitive changes
2FA enrollment requires a fresh password; 2FA disable requires a current TOTP code; backup-code regeneration requires a fresh TOTP code (not a backup code). A stolen session can't quietly rotate an authenticator out from under a legitimate user.
Data integrity
Audit log
Every operator-visible event is appended to the audit log with a stable action code, the actor, the target, and the timestamp. The full machine-readable enumeration is in Audit Log Codes. Discord-driven calls record whether they were HMAC-verified so you can spot un-upgraded fleets.
The audit log itself is written with the same data-store guarantees as the rest of data/:
- Symlink-write refusal — writes whose resolved path is a symlink fail.
- 0o600 mode on POSIX for sensitive files (
audit.json,users.json,lockouts.json,ip-bans.json,tokens-revoked.json,webhooks.json,.jwt-secret).
See: Architecture → Data directory permissions and safety.
Webhook idempotency
POST /webhooks/paddle and similar webhook receivers de-dup retries by eventId. The receiver claims an event with a 5-minute TTL, processes inside a database transaction, and stamps processedAt only on commit. A processing failure leaves the claim recoverable so Paddle's next retry can pick up cleanly — there's no silent state divergence on a transient blip.
Cloud-side hardening
The api.citadels.cc half adds production-only guards on top of the same model:
CORS_ORIGINS=*refused at boot whenNODE_ENV=production— the API will not start with a wildcard origin.- Direct-origin nginx 403 — requests that bypass Cloudflare and hit the VPS directly are refused at nginx based on a CIDR allowlist of Cloudflare's published IPv4 + IPv6 ranges. The Cloudflare-side origin firewall + "Full (strict)" SSL/TLS mode are belt-and-suspenders alongside.
- Helmet baseline —
@fastify/helmetregistered for defense-in-depth security headers on any request that bypasses the nginx layer. - Logger redaction —
Authorization,Cookie,Paddle-Signature, password fields, tokens, JWTs, TOTP secrets, etc. are masked before any log line is written. - Production fail-fast — boot fails if
JWT_SECRET,API_KEY_SALT,SESSION_SECRET,ENCRYPTION_KEY,DATABASE_URL,LICENSE_PRIVATE_KEY_B64,PADDLE_API_KEY,PADDLE_WEBHOOK_SECRET, orRESEND_API_KEYis missing in production mode.
Sidecar security
The sidecar (:9100, runs alongside the DayZ server) requires Authorization: Bearer <SIDECAR_API_KEY> on every endpoint. In production it refuses to start without a key set; in dev when running keyless it binds 127.0.0.1 only, refusing any non-loopback connection. See Sidecar API → Authentication.
Telemetry
The desktop app's diagnostic telemetry batches are optional, allowlisted by event name + payload key (no PII), per-machine rate-limited (100/hour, 5000/day), and can be HMAC-signed using a build-time embedded key. The signature path is "verify if present" today — once the desktop fleet ships the embedded key, TELEMETRY_REQUIRE_SIGNATURE=1 flips it to mandatory.
What this page doesn't cover
- In-game mod / RCON ACLs — that's the DayZ side, not Citadel's authentication surface.
- Network / firewall posture for your DayZ ports — covered in Remote Access.
- Operating system hardening — outside Citadel's scope; the docs assume reasonable Windows / Linux baselines.
Reporting issues
Found a security issue you'd like to report? Email [email protected]. We don't run a public bug bounty today but treat coordinated disclosure seriously — please give us a reasonable window before publishing.