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
4.6 KiB
paths
| paths | |||||||
|---|---|---|---|---|---|---|---|
|
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.
.envis gitignored; only.env.exampleis committed. - Every new variable is documented in
.env.examplewith a dummy value NEXT_PUBLIC_only for values safe to expose in the browser bundleBETTER_AUTH_SECRETis 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.examplein the same commit)