Files
Florian Wilde 75e99f75c6 feat: make "only the app publishes a port" a binding rule
A published database port puts the data on the public internet, and Docker's
own iptables rules bypass ufw — the server looks firewalled while the port is
open. Previously this was only a comment in compose.yaml, which an agent adding
a db service later is free to ignore.

Anchored at every point where a service could be added or shipped:
- rules/security.md: the rule, the reasoning, and the audit commands
- rules/backend.md + architecture SKILL: constraint when picking the data layer
- agents/backend-dev.md: non-negotiable for any service it adds
- agents/qa-engineer.md: a published port is a Critical finding
- skills/deploy: blocking pre-flight audit
- compose.yaml: db example now uses expose:, with depends_on healthcheck
- docs/DEPLOYMENT.md + README: SSH tunnel instead of publishing for debugging
2026-08-09 01:12:06 +02:00

4.6 KiB

paths
paths
src/app/api/**
src/lib/auth*
src/server/**
.env*
next.config.*
Dockerfile
compose.yaml

Security Rules

Authentication: Better Auth with passkeys (mandatory)

Whenever a project needs authentication, it uses Better Auth with the passkey plugin. Do not substitute another auth library, and do not build a custom session/credential layer.

npm install better-auth
# passkey support ships as a Better Auth plugin — enable it in the auth config

Passkeys are the primary method. The sign-in screen offers passkey first and presents it as the default path. A password flow is permitted only as a fallback for users whose device cannot do WebAuthn.

Requirements whenever auth is present:

  • Passkey registration is offered during sign-up, not hidden in settings
  • Existing password users are prompted to add a passkey after signing in
  • The WebAuthn relying-party ID must match the deployment hostname. It is derived from NEXT_PUBLIC_APP_URL / BETTER_AUTH_URL — a mismatch makes every passkey fail verification, and it fails silently.
  • Passkeys require HTTPS in production (localhost is exempt). The reverse proxy terminates TLS — see docs/DEPLOYMENT.md.
  • If the password fallback exists: rate-limit it, never reveal whether an account exists, and store hashes only through Better Auth's own primitives

Authorization

  • Verify the session in every route handler before any work
  • Authorization is per resource, not per route: having a session does not mean owning the record. Scope the query by the session user.
  • Never trust an ID coming from the client as proof of ownership

Secrets

  • Never commit secrets. .env is gitignored; only .env.example is committed.
  • Every new variable is documented in .env.example with a dummy value
  • NEXT_PUBLIC_ only for values safe to expose in the browser bundle
  • BETTER_AUTH_SECRET is generated per environment (openssl rand -base64 32) and never shared between staging and production

Input Handling

  • Validate all input server-side with Zod, even when the client already did
  • Never interpolate user input into SQL, shell commands or file paths
  • Escape or avoid dangerouslySetInnerHTML

Port exposure (mandatory)

Only the app service publishes a port. Nothing else. Ever.

Databases, caches, queues, search indexes, admin UIs and migration containers get no ports: entry at all. Services reach each other by service name over the Compose network — postgres://user:pass@db:5432/app works without publishing anything. Use expose: if you want the port documented; it changes nothing about reachability from outside.

db:
  image: postgres:17-alpine
  expose:
    - "5432"        # documentation only — not reachable from the host
  # ports:          # NEVER. This would put the database on the public internet.

Why this is not a style preference: ports: "5432:5432" on a public server means the only thing between an attacker and the whole dataset is the database password. Worse, Docker writes its own nat/DOCKER-USER iptables rules that bypass ufw on a typical setup — the server looks firewalled while the port is wide open. This is one of the most common ways self-hosted stacks get breached.

The app service itself binds to loopback (${APP_BIND:-127.0.0.1}) because a reverse proxy on the host terminates TLS and forwards to it. Binding 0.0.0.0 is only correct when there is no proxy, and it means serving plain HTTP — which also breaks passkeys.

If a port genuinely must be reachable for local debugging, bind it explicitly to loopback and never bare:

ports:
  - "127.0.0.1:5432:5432"   # ok for an SSH-tunnelled session
  - "5432:5432"             # WRONG — this is 0.0.0.0, the whole internet

Verify what is actually published before deploying:

docker compose config | grep -A3 "ports:"      # what the merged config declares
docker compose ps --format "{{.Service}}\t{{.Ports}}"   # what is really bound

Anything other than the app service showing a 0.0.0.0: binding is a Critical finding — treat it as such in /qa and block the deploy.

Transport and Headers

Security headers are configured in next.config.ts: X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: origin-when-cross-origin, Strict-Transport-Security. Verify they survive the reverse proxy after deploying.

Changes That Need Explicit User Approval

  • Any change to the authentication flow
  • Weakening or removing the passkey requirement
  • Any change to authorization or ownership checks
  • New environment variables (must land in .env.example in the same commit)