There are two ways to authenticate against /api/*. Browser sessions and the dashboard rely on the cookie + CSRF flow; the desktop app, scripted clients, and custom integrations use the Bearer header.
Cookie + CSRF (browser sessions)
POST /api/auth/login sets two cookies:
auth-token — HttpOnly, SameSite=Lax, Secure when served over HTTPS. The middleware reads this first and treats it as a logged-in session. JavaScript can't see it.
csrf-token — readable by JavaScript. Echo its value back as the X-CSRF-Token header on every state-changing request (POST/PUT/PATCH/DELETE). The backend signs the token and rejects mismatches with 403 csrf-mismatch.
Browser clients should send fetch requests with credentials: 'include':
Paths exempt from CSRF (no X-CSRF-Token required):
POST /api/auth/login, POST /api/auth/logout
All /api/setup/*
GET /api/health
POST /api/store/webhook (signed by Paddle)
POST /api/discord/action (signed by the Discord bot — see Discord bot doc)
Bearer token (desktop / scripted clients)
The same POST /api/auth/login returns a JWT in the response body. Custom or scripted clients pass it as a Bearer header:
Authorization: Bearer <jwt-token>
The middleware reads cookie first and falls back to Bearer, so a single login can drive either flow. Bearer is the right choice when credentials: 'include' isn't an option (CLIs, native apps, server-to-server proxies).
Endpoints
Method
Path
Auth
Description
POST
/api/auth/login
None
Authenticate with username/password. Sets auth-token + csrf-token cookies AND returns JWT in body. Per-(IP, username) brute-force lockout: 5 attempts, escalating 60s → 5min → 1h.
POST
/api/auth/logout
Any auth
Clears the auth-token cookie and revokes the JWT. Exempt from CSRF.
Centralized ban system with UUID-based shareable ban IDs. Bans apply to all servers and are automatically synced to each server's ban.txt on start/restart.
Automated VIP system that syncs entries to DayZ's native priority.txt file. Players in the queue get priority position in the login queue. Entries support time-limited expiration and are automatically cleaned every 60 seconds.
These endpoints are how the official Citadel Discord bot drives the controller. They have their own auth contract — see the Discord Bot guide for the full 3-layer security model.
POST /api/discord/action
Executes a Discord-initiated action against a Citadel server. The bot signs each call with HMAC-SHA256 over (timestamp, action, discordUserId) using the shared bot secret.
The backend resolves the calling Discord user against data/discord-user-roles.json (Layer 3) → falls back to the built-in discord-bot role (Layer 1). If the resulting role doesn't grant the permission ACTION_PERMISSIONS[action] requires, the call is rejected with 403 and a discord.denied audit row.
Stale or replayed signatures (timestamp older than the configured window, or HMAC mismatch) return 403 with discord.sig-rejected. Calls that don't carry the HMAC headers at all still execute (legacy bots) but get tagged as "Discord Bot (unverified)" in the audit log.
Per-Discord-user role mapping
Method
Path
Permission
Description
GET
/api/discord/user-roles
users.manage
List all per-Discord-user role mappings.
PUT
/api/discord/user-roles/:discordUserId
users.manage
Body { "role": "moderator" }. Sets/replaces the mapping. Logged as discord.user-role.set.
DELETE
/api/discord/user-roles/:discordUserId
users.manage
Removes the mapping (user falls back to the discord-bot role floor). Logged as discord.user-role.remove.
Rate limits
The API enforces a per-IP global cap plus tighter scoped caps on auth and bot paths. All limits are per-IP (not per-user) and use a 1-minute sliding window unless noted.
Scope
Limit
Notes
Default /api/*
600 / min
Global ceiling on any /api endpoint not covered by a tighter scope.
Bot-driven action volume. The bot's per-Discord-user cooldowns (in the Discord bot guide) sit on top of this.
/api/maps/tiles/*
exempt
Map tiles are served at burst from browser caches; rate-limiting them would break the live-map UI on busy panels.
A request that trips a limit returns 429 Too Many Requests with a Retry-After header in seconds.
Login lockout
Failed sign-ins are tracked per (IP, username) rather than per-username alone, so an attacker who knows a target's username can't lock that account from arbitrary IPs. Five failures within 10 minutes from the same (IP, username) triggers a fail2ban-style escalating ban: first lockout 60s, second 5min, third+ 1h. A successful sign-in clears the counter.
This means: you'll never get locked out of your own account because someone else is spamming wrong passwords for it from a different IP.