← All recipes

Recipe: security in every layer

1. Problem

Security is not a feature you add; it is a property of every layer, and a single missing control is the one an attacker uses. A ticketing sale handles credentials, money, and a scarce resource people will cheat for. The question this recipe answers: where is each defence, and can you point at it in the code?

2. Concept

Defence in depth — each layer assumes the ones around it might fail:

  • Edge: TLS, rate limiting, security headers, restrictive CSP.
  • Gateway ↔ service: mutual TLS (never trust the network).
  • Application: authn/authz, strict input validation, idempotency, error envelopes that leak nothing.
  • Data: least-privilege database role, hashed passwords, encryption at rest.

3. Implementation — the section-8 checklist, mapped to code

ControlWhere
JWT access + refresh rotationplatform/token.go; recipe: jwt-refresh-rotation
mTLS gateway ↔ backendcmd/server/main.go + infra/tls/; recipe: mtls-gateway-backend
Rate limiting (edge + per-user)infra/gateway/dynamic.yml, redisadp.go; recipe: rate-limiting
Input validationevery use case validates before acting (reservation.go: qty/idem-key; DTOs reject malformed bodies → 400/422)
TLS at the edgeTraefik websecure + self-signed dev cert (infra/gateway/)
Secrets from env, never in codeconfig packages read env; .env/certs gitignored; ADR 0004
Errors leak nothingone envelope, {code, message, request_id}; internal errors collapse to a generic 500 (domain/errors.go, transport filters)
CORS restrictive / security headerssame-origin SPA (no CORS needed); HSTS/nosniff/frame-deny at the gateway, CSP from the SPA's nginx
Webhook signature verificationHMAC-SHA256, constant-time compare (handlers.go validSignature)
Least privilegeapp connects as ticketing_app (SELECT/INSERT/UPDATE/DELETE only, no DDL); verified it is denied CREATE TABLE (ADR 0004, 0002_app_role.sql)

4. Encryption at rest

  • Passwords are never stored, encrypted or otherwise — they are bcrypt hashes (platform BcryptHasher), which is the correct treatment (one-way, salted, slow). A breach of the DB does not reveal passwords.
  • Refresh tokens live in Redis with a TTL and are opaque handles, not secrets that decrypt anything.
  • Sensitive columns / disk: the seed carries no card numbers or PII beyond email, so there is nothing here to field-encrypt. The at-rest strategy for a real deployment is documented rather than implemented: encrypt the Postgres and Redis volumes (cloud-managed disk encryption, or LUKS), and for individual sensitive columns use pgcrypto (pgp_sym_encrypt) with keys from the same secret store the app secrets come from. This is the one checklist item the lab documents but does not build, because there is no sensitive payload in the domain to encrypt.

5. How to see it working

Each control has its own verification (see the linked recipes). Quick tour:

curl -sk https://localhost/api/events/00000000-0000-0000-0000-000000000000   # error envelope, no stack trace
curl -sk -XPOST https://localhost/api/webhooks/payment -d '{}'                # 401: unsigned webhook rejected
# least privilege: the app role cannot alter schema
docker compose exec -e PGPASSWORD=app_local_dev_only postgres psql -U ticketing_app -d ticketing -c "CREATE TABLE x(i int);"  # permission denied

6. Trade-offs

  • Self-signed TLS and a committed dev CA script are for local only. Production uses a real CA (ACME/cert-manager) and a secret manager; the lab is explicit that anything ending in _local_dev_only protects nothing real.
  • mTLS and full at-rest encryption are shown/documented on the reference path, not wired into all seven backends or the datastores — deliberate scoping, called out so the gap is visible rather than implied-complete.
  • Defence in depth costs latency and moving parts. Every layer is another thing to operate. The payoff is that no single failure is fatal; the cost is that there is more to run and rotate.