Skip to content

Security Model

The defaults assume the app runs on a network you trust — your LAN, or a Tailscale network. They do not assume the open internet. This page says exactly what that means, so you can decide what to change before exposing it more widely.

The stack publishes one port: 4263, the same on the host and in the container. Everything is behind it: the API, the React app, and the authenticated /storage proxy that serves avatars and uploaded images.

Every route requires a logged-in session except these:

RouteWhy it is open
GET /api/healthLiveness check for the container healthcheck
POST /api/auth/loginYou need to be able to log in
POST /api/auth/forgot-password, POST /api/auth/reset-passwordSelf-service password reset, if enabled
GET /metricsOnly when you set METRICS_ENABLED=true — see below

There is no public signup. Accounts exist only if an admin creates them, so an internet-exposed instance is not a spam target the way an open-registration app is.

One more thing becomes public, but only if you turn it on: setting PUBLIC_RECOMMENDED_ENABLED=true (default false for self-hosted — see App service variables) exposes the landing page’s recommended-games list, GET /api/recommended*, without a session — the same behavior the AWS deployment defaults to. Leave it false if you want nothing at all visible without logging in.

No TLS. The container serves plain HTTP. Passwords and session cookies cross the network unencrypted unless you put a TLS-terminating proxy in front — see HTTPS / Reverse Proxy. This is the one that matters most.

ORIGIN_GUARD_ENABLED=false. The origin guard is a shared-secret header check that only makes sense behind CloudFront, where the CDN injects the header and the origin rejects anything else. Self-hosted, there is no CDN to inject it, so it is off. Your reverse proxy is the access-control boundary instead.

METRICS_ENABLED is off, and unauthenticated when on. /metrics sits behind no auth of its own. Enabling it while the app is publicly reachable publishes your request counts, latencies, and route names to anyone who asks. Scrape it from inside your Docker network, never through the public proxy.

Passwords are hashed with bcrypt at cost 12 and are never logged or returned by the API. A minimum length of 12 characters is enforced server-side — by the API itself, not just the browser, so it holds for direct curl calls too. There are no composition rules (no forced symbol or digit), following current NIST guidance: length does the real work, while composition rules mostly push people toward predictable patterns.

Sessions are JWTs in an HttpOnly, SameSite=Strict cookie, valid for 7 days. HttpOnly keeps JavaScript from reading the token, and SameSite=Strict is what defends against CSRF. There is no idle timeout: a session lives its full 7 days unless invalidated.

Each user has a tokenVersion. Bumping it invalidates every existing session for that user immediately:

Terminal window
docker compose exec app python3 scripts/revoke-sessions.py --username alice

Changing a password bumps it too, so a password reset signs out every other device.

Generated on first boot, stored at /data/.jwt_secret (mode 0600) inside the bind-mounted config/app/ directory. It is never written to .env, never logged, and never served over HTTP — /data is outside both STATIC_DIR and the /storage proxy’s allowed prefixes.

Anyone holding it can mint a valid session for any user, so treat it like a password: it is the most sensitive thing in your backups. To rotate it — after a lost backup or a compromised host — see Rotating the JWT secret. Every session everywhere is invalidated.

Login and password-reset accept 5 requests per minute per IP. Behind a reverse proxy this only works if the app can see the real client IP; FORWARDED_ALLOW_IPS already trusts private and Docker-internal ranges, which covers every proxy setup in the HTTPS guide. Get this wrong and every visitor shares one bucket — see Reverse proxy and rate limiting.

Uploaded images are constrained on the way in and on the way out:

  • The stored file extension comes from the allowlisted content type, never from the filename a client sends, so an evil.svg uploaded as image/png is stored as .png
  • The /storage proxy serves only the avatars/, blog-images/, and game-images/ prefixes; nothing else under /data is reachable through it
  • Blog HTML is sanitised with DOMPurify before rendering

Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY (clickjacking), Referrer-Policy: strict-origin-when-cross-origin, HSTS, and a Content-Security-Policy restricting scripts to same-origin.

Please do not open a public issue. Use GitHub’s Security → Report a vulnerability on the repository, which opens a private advisory. This is a hobby project with no formal SLA, but reports get read.