Security & Secrets
Secrets are never committed
All deployment secrets live in a git-ignored .env next to the deployment's compose.yaml
(e.g. deployments/prod/.env); the compose files reference them with ${VAR:?} placeholders that
fail startup loudly if unset. Generate each by hand:
| Variable | Generate with |
|---|---|
SNITCHER_JWT_KEY | openssl rand -base64 48 (min 32 chars) |
SNITCHER_SECRETS_KEY | openssl rand -base64 32 |
SNITCHER_DB_PASSWORD | openssl rand -base64 24 |
SNITCHER_ADMIN_PASSWORD_HASH | PBKDF2 — see the recipe in deployments/prod/README.md |
The dev stack (deployments/dev/) bakes in throwaway defaults, so it needs none of these.
Authentication
- The local admin (the single env-var-configured account,
Auth__Username) password is stored as a PBKDF2-HMAC-SHA256 hash (Auth__PasswordHash) and compared in constant time. A plaintextAuth__Passwordis honoured only in Development. - Every other local user account (created under Administration → Users, or self-registered) hashes its password with Argon2id — memory-hard, so it resists GPU/ASIC cracking far better than PBKDF2. Accounts created before this change keep authenticating against their existing hash and are transparently upgraded to Argon2id the next time they sign in successfully; no bulk migration or forced reset is needed.
- JWTs are HS256. In non-Development, startup fails fast if
Auth__JwtKeyis shorter than 32 characters or is a known dev default. Token clock-skew tolerance is zero. - Login rate-limiting and a short token lifetime keep brute-forcing impractical; the frontend attaches the bearer token only to same-origin requests. See Captcha and Fail2ban: IP bans & account lockouts for the additional layers guarding the sign-in form itself, and Two-factor authentication (TOTP) for the optional second factor.
Password policy
Every local-account password — whether set by a user changing their own, or via a reset link — must satisfy a configurable complexity policy before it's accepted:
| Rule | Default |
|---|---|
| Minimum length | 12 characters |
| Minimum digits | 1 |
| Minimum uppercase letters | 1 |
| Minimum lowercase letters | 1 |
| Minimum special (non-alphanumeric) characters | 1 |
Optionally, Snitcher can also reject any password found in the Have I Been Pwned breached-password database (checked via HIBP's k-anonymity range API — only a prefix of the password's hash ever leaves the server, the full password/hash never does). This check is on by default and can be turned off. If the HIBP service is unreachable, the check fails open (treated as "not pwned") so an outage never blocks a legitimate password change — the complexity rules above are still always enforced regardless.
All of these are configurable under Settings → Security. The sign-up/change-password forms call a live policy-check endpoint as you type, so you see which rules are unmet before submitting.
Two-factor authentication (TOTP)
Any local user can turn on optional TOTP-based two-factor authentication for their own account, from their profile/security settings:
- Enroll. Snitcher generates a new secret and shows it as a QR code (scan with an authenticator app such as Google Authenticator, Authy, or a password manager's built-in TOTP support) plus a manual-entry key for apps that can't scan.
- Confirm. Enter the 6-digit code your app currently shows to prove the enrollment worked. On success, Snitcher shows 8 single-use recovery codes — save these somewhere safe; they're shown exactly once and are your only way back in if you lose the device.
- Confirm you saved them. You must submit one of the 8 recovery codes back to Snitcher to finish turning 2FA on. This doesn't consume the code — it just proves you actually copied the codes down before they're needed. Two-factor is only enforced at sign-in after this final step.
At sign-in, once 2FA is enabled, entering the correct password is not enough: Snitcher prompts for the current 6-digit code from your authenticator app before issuing a session. A wrong (but present) code counts the same as a wrong password toward the fail2ban thresholds below; a missing code (the initial prompt, before you've entered anything) does not, since you haven't actually attempted and failed a guess yet.
Lost your device? A recovery flow lets you replace a lost authenticator without an admin's help, and it never signs you in directly:
- Request recovery by entering your username/email and solving the captcha — Snitcher emails a single-use recovery link (default validity: 15 minutes, configurable under Settings → Security).
- Follow the link and enter one of your saved recovery codes. Three wrong codes invalidate the link.
- On success, Snitcher immediately re-enrolls you with a fresh QR code and a new batch of 8 recovery codes, and emails a confirmation notice so you'd notice if this wasn't you. You still need to sign in normally afterward with the new device — this flow only replaces your authenticator, it never hands out a session.
This request step is deliberately non-enumerating: the response is the same generic confirmation message regardless of whether the account exists, has 2FA enabled, or is an OIDC account — see non-enumerating design below.
Account recovery links
Local-account recovery is link-based — Snitcher never lets one person set another account's password directly:
- Password reset. An admin with the Reset password permission on
Userclicks Send reset link on a user. Snitcher generates a cryptographically-random, single-use token, stores only its SHA-256 hash (the plaintext is never persisted or logged), and emails a link to the account's own registered address. The owner sets their own new password via that link — subject to the same password-complexity policy enforced everywhere else a password is set. The token is rejected once used or expired, and using it invalidates any other outstanding reset links for the account. Completing a reset also signs out every other active session for the account (any previously issued login token stops working) and clears an account lockout if fail2ban had locked it, so the owner regains access immediately with their new password. - Forgot password? A signed-out user doesn't need an admin: the "Forgot password?" link on the sign-in page requests the same kind of link themselves, after solving a captcha. The response is always the identical generic confirmation ("If this email is registered, a link has been sent.") whether or not the account exists, is local, or is OIDC — this endpoint never confirms or denies account existence, and equalizes response timing across every branch so it can't be used as a timing oracle either. An OIDC account still gets an email, but it points them to their identity provider instead of a reset link.
- Email-change revert. When a user changes their notification email, a notice with a single-use revert link is mailed to the previous address. Using it restores the old email and forces a fresh password reset (through the same token mechanism) — closing the "change the email, then reset the password" account-takeover path.
- The reset, forgot-password-request, and revert endpoints are the recovery mechanism, so they are reachable without signing in; they are rate-limited (and the forgot-password/TOTP-recovery request steps are also captcha-guarded) and return a generic error for any invalid/expired/used token, so they can't be used to probe whether an account exists.
Both link lifetimes are configurable under Settings → Security (GET/PUT /api/Settings/security):
the password reset link lifetime (minutes, default 15) and the email-change revert link
lifetime (days, default 7). Email delivery uses the same SMTP configuration as alerting.
Captcha
Snitcher guards every anonymous form that sends email or checks a credential at volume with a captcha: sign-in, forgot-password request, and TOTP "lost my authenticator?" request. Two providers are supported, chosen under Settings → Security (mutually exclusive — only one is active at a time):
- ALTCHA (default, "Built in") — a privacy-friendly proof-of-work challenge built into Snitcher. Requires no external account or keys; just works out of the box.
- Cloudflare Turnstile — a hosted, invisible-first challenge. Requires a free Turnstile site key and secret key from your Cloudflare dashboard, entered under Settings → Security.
Switching providers takes effect immediately for every guarded form; no restart required.
Fail2ban: IP bans & account lockouts
Snitcher throttles repeated failed sign-in attempts (wrong password, or a wrong (but present) TOTP code once 2FA is enabled) with two independent, layered mechanisms, both configurable under Settings → Security:
| Scope | Default threshold | Default duration | Effect | |
|---|---|---|---|---|
| IP ban | Any username, from one source IP | 3 failed attempts | 3 hours | That IP is rejected outright, before credentials are even checked |
| Account lockout | One username, across distinct source IPs | 15 failed attempts | 3 hours | That account can't sign in from anywhere, even the correct password |
A successful sign-in clears the failure count for that account. An account lockout only blocks new sign-ins — it does not sign out sessions that were already active. The first time an account locks (not on every subsequent attempt while already locked), Snitcher emails the account owner a single-use unlock link that bypasses the lockout — its validity tracks however long the lockout itself would last. Using it clears only the account lock; an independent IP ban, if one is also in effect, is unaffected.
Completing a password reset also clears an account lockout, so a user who resets their password after being locked out regains access immediately.
Stored target secrets
HTTP passwords, bearer tokens, and secret headers are encrypted with AES-GCM-256 (per-message
nonce) using SNITCHER_SECRETS_KEY, and are never returned by the API.
Rotating SNITCHER_SECRETS_KEY after secrets are stored makes them permanently undecryptable.
Scout credential handling
Scouts authenticate with a per-relationship Ed25519 keypair, not a shared secret — the Scout holds the private key, Snitcher only ever stores the public key — and that key rotates automatically over time. See Scout Authentication & Key Rotation for how the enrolment handshake, steady-state signing, and rotation actually work. For what to do if a key lapses, see Untrusted Scouts.
HTTP checks drop Basic/Bearer credentials on a cross-host or scheme-changing redirect, so a 3xx to an attacker host can't exfiltrate a target's auth secret.
Egress protection (SSRF guard)
Every check (HTTP, TCP, ICMP) and every outbound alert webhook (Webhook/Slack/Discord) shares the same
guard: it re-resolves the target's DNS at send time and blocks loopback, RFC1918 private ranges,
link-local addresses, and cloud metadata endpoints (169.254.169.254, 168.63.129.16). Re-resolving
at send time — rather than trusting a cached lookup — is what defeats DNS-rebinding attempts that try
to swap a benign address for an internal one after validation.
HTML sanitization
Status-page descriptions, incident bodies, and maintenance messages are sanitized server-side with a strict allowlist (no scripts or event handlers) before storage, then rendered through Angular's sanitizer as a second layer. See Status pages.
Browser hardening headers
Every response carries a small set of security headers, set centrally in the request pipeline:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Block MIME-sniffing |
Referrer-Policy | no-referrer | Never leak URLs to third parties |
Content-Security-Policy | frame-ancestors …; object-src 'none'; base-uri 'self'; form-action 'self' | Anti-clickjacking, plus no plugins, no <base> hijacking, no off-site form posts |
X-Frame-Options | DENY (default) | Legacy clickjacking guard — see framing below |
Strict-Transport-Security | (non-Development only) | HSTS is emitted outside Development; it is deliberately not sent over the dev http stack |
The CSP intentionally omits script-src/style-src for now — the SPA shell runs an inline theme
bootstrap script and Angular injects inline styles, so tightening those requires nonces/hashes first (a
tracked frontend follow-up). The directives above don't restrict resource loading, so they never break
the bundled SPA.
Framing (clickjacking)
By default Snitcher refuses to be embedded in an <iframe> anywhere — X-Frame-Options: DENY and CSP
frame-ancestors 'none'. Two opt-in settings widen this. Each takes a CSP frame-ancestors source
list: space-separated origins (https://a.example https://b.example), 'self', or a wildcard
(https://*.example).
| Variable | Scope |
|---|---|
Snitcher__Security__FrameAncestors | The whole origin — including the authenticated admin dashboard and login page |
Snitcher__Security__StatusPageFrameAncestors | Only the public status pages (/status/*); the admin control plane stays at DENY. Falls back to the global value when unset |
When a value is set, X-Frame-Options becomes SAMEORIGIN for exactly 'self' and is otherwise
dropped — it can't express a cross-origin allowlist, so modern browsers rely on frame-ancestors.
To embed a status page on another site without ever making the admin UI frameable, set only the status-page-scoped variable:
Snitcher__Security__StatusPageFrameAncestors: "https://www.example.com"
Snitcher__Security__FrameAncestors opens framing for the entire origin, admin dashboard included.
Prefer StatusPageFrameAncestors unless you specifically need the whole app embeddable (e.g. a demo
portal that proxies Snitcher same-origin).
Container hardening
Both the Snitcher and Scout images build FROM gcr.io/distroless/cc-debian13:nonroot — no shell, no package
manager, and a fixed non-root user (UID/GID 65532). Because there is no curl/sh in the image, each
service's own binary doubles as its Docker HEALTHCHECK (/app/Snitcher.Api --healthcheck /
/app/Snitcher.Scout --healthcheck — a loopback GET /health). The Scout's ICMP checks use .NET's own ping
over an unprivileged socket, enabled by --cap-add=NET_RAW plus a net.ipv4.ping_group_range
sysctl scoped to the nonroot GID (65532 65532) — that single sysctl governs both IPv4 and IPv6 echo
(there is no net.ipv6.ping_group_range). Images are published as multi-arch manifests
(linux/amd64, linux/arm64, linux/arm/v7).
Authorization
Every guarded action requires a permission, enforced centrally in the request pipeline (403 on
failure, audit-logged as Denied). See Users & Groups.
Auditing & traceability
Every guarded mutation is captured automatically with a field-level before/after diff; sensitive
values (passwords, tokens, secrets, anything already encrypted at rest) are redacted from the stored
diff. A correlation-ID middleware accepts or generates a request ID and threads it through audit
records and logs, so a single request can be traced end to end. CSV exports (audit log and elsewhere)
guard against formula injection by quoting cells that start with =+-@. See
Auditing.
Going to production
- Use the production stack (
deployments/prod/) and generate your own secrets — seedeployments/prod/README.md(nothing from the dev stack should carry over). - Put Snitcher behind TLS (a reverse proxy); ensure the Scout hub is reached over WSS.
- Restrict the Keycloak admin console and use real credentials, or disable OIDC if unused.