# Operational notes
Source: https://docs.mantis.privacykey.org/operational-notes

Implementation notes for key hashing, disabled-key behavior, and worker mode.

- **API keys are stored as peppered HMAC-SHA-256 hashes.** Keys are random 192-bit keys with the `mantis_live_` prefix (matches GitHub's secret-scanning format, so a leaked key in a public repo will trigger their alerting). `MANTIS_API_KEY_PEPPER` is required for new mints; legacy pre-pepper SHA-256 rows are still accepted and are upgraded opportunistically on next use. Dashboard cookies carry opaque `mantis_sess_...` tokens; the database stores only a SHA-256 hash of each session token.
- **Disabled or expired keys** still return the configured response — they just don't record a hit or fire notifications. This prevents an attacker from probing for valid key IDs by looking for differential responses.
- **Notifications use a Postgres-backed queue.** Public trigger requests enqueue notification rows after the response path; a worker claims pending rows with `FOR UPDATE SKIP LOCKED`, sends them, and retries failures with backoff. The worker starts automatically on non-Vercel deployments. On Vercel or other serverless hosts, use `/api/cron/notifications` with `CRON_SECRET`.
- **Operator secrets can be encrypted at rest.** Set `MANTIS_SECRET_KEY` (AES-256-GCM, `openssl rand -base64 32`) to envelope-encrypt webhook HMAC signing secrets and the Apple Wallet auth secret + cert passphrase in the database, so a DB-only leak yields no usable secrets. Unset = today's plaintext-at-rest behavior; existing rows are read transparently and migrate to ciphertext the next time a signing secret is rotated (a legacy DB wallet config can't be re-saved — clear it from `/settings/wallet` instead). See [Configuration](https://docs.mantis.privacykey.org/configuration#required).
- **API-key auth failures are rate-limited.** Repeated failed bearer auth is throttled per client IP via a Postgres-backed fixed-window limiter (60/min) and returns `429` instead of `401` to blunt brute force; a successful auth never touches the limiter. The dashboard login action applies the same per-IP throttle to *failed* logins only (10/min), and only when a trusted client IP is available — so a flood of bad attempts can never lock out a valid key or the operator. The shared `rate_limits` table makes the cap hold across instances and serverless cold starts; the limiter fails open on a DB error.
- **Alert payloads escape attacker-controlled values.** The hit IP, User-Agent, key memo, and the `host_context` parsed from `X-Mantis-*` headers are escaped before they're interpolated into Slack / Discord / Teams messages, so a canary tripper can't inject phishing links, channel mentions, or spoofed formatting into your alerts. The edge worker carries the same escapers and additionally drops the request query string from alert links.
- **Outbound webhooks are SSRF-guarded.** Webhook / Slack / Discord / Teams deliveries enforce `http(s)`-only and refuse targets that resolve to RFC1918, loopback, link-local, or cloud-metadata (`169.254.169.254`) addresses, don't follow redirects, and re-validate the resolved address at connect time to close the DNS-rebinding window. Set `ALLOW_PRIVATE_WEBHOOKS=1` only when your webhook target is intentionally on the same LAN.
