Documentation menu

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

POST /api/auth/login issues two cookies:

  • auth-token — HttpOnly, SameSite=Lax, Secure over HTTPS. JavaScript can't read it. The middleware uses this as the session.
  • csrf-token — readable by JavaScript. Echo it back as X-CSRF-Token on every POST / PUT / PATCH / DELETE. Mismatches return 403 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:

RoleScope
supportCustomer lookup, resend welcome, view webhooks
financeEverything support can + full subscription/transaction details
adminFull 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:

  1. Citadel role permissions. Every /api/discord/action call is gated against the built-in discord-bot role. Defaults to * so existing deployments work; tighten to e.g. server.view + server.restart + players.kick + chat.send to bound the bot.
  2. Verified attribution. Each call is HMAC-SHA256-signed over (timestamp, action, discordUserId) via X-Discord-Ts + X-Discord-Sig. Mismatches → 403 with discord.sig-rejected. Stealing the API key alone no longer lets you forge attributed actions.
  3. Per-Discord-user role mapping. data/discord-user-roles.json lets 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 when NODE_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/helmet registered for defense-in-depth security headers on any request that bypasses the nginx layer.
  • Logger redactionAuthorization, 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, or RESEND_API_KEY is 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.