Skip to content

ADR-014: Security Patterns and Threat Model

Accepted (amended by ADR-025 §1, ADR-027 §4, ADR-028 §6, ADR-034 §4)

Security must be designed into the application from the start. Without explicit security patterns and threat modeling, vulnerabilities emerge organically. This ADR establishes security baseline requirements, covering authentication, authorization, input validation, CSRF protection, and secrets management.

The application must protect against the OWASP Top 10 threats while maintaining performance and usability.

Amended 2026-09-30 (docs health audit; append-only per ADR-033): ADR-001 does not mention Supabase — the auth choice is made here and in ADR-024. There is no sliding session: the access token rides the sb-access-token cookie and the refresh token is used exactly once, during guest→registered upgrade (ADR-024). Session carriage is amended by ADR-025 §3.

  • Use Supabase Auth for identity management (as referenced in ADR-001)
  • JWT tokens validated on every request
  • Token expiration: 1 hour (refresh tokens: 30 days)
  • Session management via HTTP-only cookies

Session Model (documented 2026-07-05, per ADR-025)

Section titled “Session Model (documented 2026-07-05, per ADR-025)”

Sessions are stateless: the Supabase JWTs are the session, carried in two cookies — sb-access-token (1h) and sb-refresh-token (30d) — set with HttpOnly, SameSite=Lax, and Secure in production. There is no server-side session store; any instance can serve any request (see ADR-025). Logout clears both cookies and signs out of GoTrue. Anonymous guest sessions (ADR-024) use the identical mechanism — the JWT simply carries the is_anonymous claim.

  • Row Level Security (RLS) for data access control (as defined in ADR-004)
  • Multi-tenant isolation enforced at database layer
  • Application-level permission checks for complex business logic
// Authentication middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
claims, err := validateJWT(token)
if err != nil {
log.Warn().Err(err).Msg("invalid token")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Add user context to request
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "org_id", claims.OrgID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Amended 2026-09-30 (docs health audit; append-only per ADR-033): the bluemonday sample is illustrative; the app ships no HTML sanitiser. templ escapes every interpolated value and inputs are length-validated in internal/validate.

  • Never trust client input - validate on server
  • Whitelist over blacklist - allow known-good inputs only
  • Fail closed - reject invalid input, don’t try to fix it
  • Type safety - use Go’s type system to enforce constraints
// Validation helper functions
func validateEmail(email string) error {
if email == "" {
return errors.New("email is required")
}
if !emailRegex.MatchString(email) {
return errors.New("invalid email format")
}
if len(email) > 255 {
return errors.New("email too long")
}
return nil
}
func validateSlug(slug string) error {
if slug == "" {
return errors.New("slug is required")
}
// Whitelist: alphanumeric and hyphens only
if !slugRegex.MatchString(slug) {
return errors.New("invalid slug format")
}
if len(slug) > 100 {
return errors.New("slug too long")
}
return nil
}
// Form validation example
func HandleContactForm(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
message := r.FormValue("message")
// Server-side validation (never rely on client-side alone)
if err := validateEmail(email); err != nil {
renderError(w, err.Error())
return
}
if len(message) == 0 || len(message) > 5000 {
renderError(w, "message must be 1-5000 characters")
return
}
// Sanitize HTML input
message = bluemonday.StrictPolicy().Sanitize(message)
// Process form...
}
  • Always use parameterized queries - never string concatenation
  • Use SQLC for compile-time SQL validation (as defined in ADR-003)
  • No dynamic SQL construction from user input
// ✅ Safe: parameterized query
func GetPost(slug string) (*Post, error) {
var post Post
err := db.QueryRow(
"SELECT id, title, content FROM posts WHERE slug = $1",
slug, // Parameter binding prevents SQL injection
).Scan(&post.ID, &post.Title, &post.Content)
return &post, err
}
// ❌ NEVER DO THIS: SQL injection vulnerability
func GetPostUnsafe(slug string) (*Post, error) {
query := "SELECT * FROM posts WHERE slug = '" + slug + "'"
// Attacker can inject: ' OR '1'='1
// ...
}

Amended 2026-09-30 (docs health audit; append-only per ADR-033): gorilla/csrf is not a dependency. The double-submit cookie is hand-rolled in internal/middleware/csrf.go: 32-byte random token in an HttpOnly cookie (12h), sent back via the X-CSRF-Token header (hx-headers on <body>) or the csrf_token field (components.CSRFField), compared in constant time. No per-session binding, no rotation.

  • Generate unique token per session
  • Include token in forms via hidden input
  • Validate token on state-changing operations (POST, PUT, DELETE)
  • Token rotation on sensitive actions
import "github.com/gorilla/csrf"
// CSRF middleware
func setupCSRF(r chi.Router) {
csrfMiddleware := csrf.Protect(
[]byte("32-byte-secret-key"), // Store in environment variable
csrf.Secure(isProd), // HTTPS-only in production
csrf.SameSite(csrf.SameSiteStrictMode),
)
r.Use(csrfMiddleware)
}
// Template helper
func renderForm(w http.ResponseWriter, r *http.Request) {
tmpl.Execute(w, map[string]interface{}{
"CSRFToken": csrf.Token(r),
})
}
<form action="/contact" method="POST">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="email" name="email" required>
<button type="submit">Submit</button>
</form>

Amended 2026-07-07 by ADR-027 and 2026-09-09 by ADR-034 (note added 2026-09-30): per-client tiers only see real visitor IPs behind a proxy when TRUSTED_PROXY_CIDRS/CLIENT_IP_HEADER are set (ADR-027). Shipped tiers (internal/server/server.go): global 50 req/s burst 10; /auth credential POSTs 5/min; /learn 30/min burst 20; the /patterns rate-limit demo 3 then one per 2s. The per-user and per-email tiers listed below are not implemented (OWASP table, A07). Every 429 carries Retry-After (ADR-034).

  • Authentication endpoints: 5 attempts per minute per IP
  • API endpoints: 100 requests per minute per user
  • Public pages: 1000 requests per minute per IP
  • Password reset: 3 attempts per hour per email
import "golang.org/x/time/rate"
// Rate limiter middleware
func rateLimitMiddleware(limiter *rate.Limiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// Per-user rate limiting
func perUserRateLimit(next http.Handler) http.Handler {
limiters := sync.Map{}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
limiter, _ := limiters.LoadOrStore(userID,
rate.NewLimiter(rate.Every(time.Minute/100), 10))
if !limiter.(*rate.Limiter).Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

Amended 2026-09-30 (docs health audit; append-only per ADR-033): production secrets live on the container host (fly secrets; ADR-015, ADR-025), not in Cloudflare environment variables. adr015-no-hardcoded-secrets scans shipped source and config in task check:adr.

  • Use .env files loaded via godotenv (as defined in ADR-001)
  • NEVER commit .env to version control
  • Provide .env.example with placeholder values
  • Use Cloudflare Environment Variables or equivalent
  • Rotate secrets regularly (every 90 days minimum)
  • Use different secrets per environment (dev/staging/prod)
  • No hardcoded secrets in code
// ✅ Safe: load from environment
func loadConfig() (*Config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, errors.New("DATABASE_URL not set")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, errors.New("JWT_SECRET not set")
}
return &Config{
DatabaseURL: dbURL,
JWTSecret: jwtSecret,
}, nil
}
// ❌ NEVER DO THIS: hardcoded secrets
const (
DatabaseURL = "postgres://user:password@localhost/db"
APIKey = "sk_live_abc123"
)
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Prevent MIME sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// XSS protection
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Content Security Policy
// (Amended 2026-07-12: the live policy is stricter than this sketch —
// script-src is 'self' 'unsafe-eval', with inline scripts forbidden.
// 'unsafe-eval' is required by Alpine 3's expression engine; see
// ADR-028-CSP-Alpine-Compatibility.)
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
// HTTPS enforcement (production only)
if isProd {
w.Header().Set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload")
}
// Referrer policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions policy
w.Header().Set("Permissions-Policy",
"geolocation=(), microphone=(), camera=()")
next.ServeHTTP(w, r)
})
}
  • NEVER log passwords, tokens, or API keys
  • NEVER log full credit card numbers (PCI DSS)
  • Scrub sensitive fields from logs automatically
// Log scrubbing example
func sanitizeLog(data map[string]interface{}) map[string]interface{} {
sensitiveFields := []string{"password", "token", "api_key", "secret"}
for _, field := range sensitiveFields {
if _, exists := data[field]; exists {
data[field] = "[REDACTED]"
}
}
return data
}
  • Encrypt sensitive columns at rest (e.g., PII, payment info)
  • Use PostgreSQL pgcrypto extension for column-level encryption
  • Store encryption keys separately from database
  • Pin exact dependency versions in go.mod
  • Run go mod verify in CI to detect tampering
  • Scan dependencies for known vulnerabilities (govulncheck)
  • Review dependency licenses for compliance
# GitHub Actions security scanning
- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
- name: Verify dependencies
run: go mod verify

Amended 2026-07-05: statuses corrected against the deployment-readiness audit. The original table marked several mitigations “Implemented” that exist in this ADR but not in code. Do not mark a row ✅ until the code and a test exist.

Amended 2026-08-02: statuses re-verified against the current tree. The hardening-phase gaps from the 2026-07-05 correction have since landed: per-request JWT-claim propagation (A01), HSTS gating and server timeouts (A05), route-scoped rate-limit tiers (A07, partial), CSRF middleware (A08), and slog standardization per ADR-026 (A09).

ThreatMitigationStatus
A01: Broken Access ControlRLS + Authentication middleware✅ Implemented — every repository method runs through inScope() (internal/repository/postgres/scope.go), which applies SET LOCAL ROLE + request.jwt.claims per transaction so RLS evaluates against the requester at runtime
A02: Cryptographic FailuresHTTPS, encrypted secrets, JWT✅ Implemented (TLS terminates at the edge per ADR-025)
A03: InjectionParameterized queries, input validation✅ Implemented
A04: Insecure DesignThreat modeling, secure defaults✅ Implemented
A05: Security MisconfigurationSecurity headers, hardened defaults✅ Implemented — SecurityHeaders(isProd) gates HSTS on ENV=production (§6); server timeouts set in cmd/api/main.go (ReadHeaderTimeout 5s, ReadTimeout 15s, WriteTimeout 45s, IdleTimeout 120s)
A06: Vulnerable ComponentsDependency scanning, pinning✅ Implemented
A07: Authentication FailuresRate limiting, JWT expiration⚠️ Partial — route-scoped tiers wired: global IP limiter (50 rps, burst 10), learn routes 30/min, auth-sensitive actions 5/min (the §4 auth tier); the §4 per-user and per-email tiers are not yet implemented
A08: Data Integrity FailuresCSRF tokens, signed requests✅ Implemented — double-submit cookie CSRF middleware (internal/middleware/csrf.go, §3) applied globally in the server middleware stack
A09: Logging FailuresStructured logging, log scrubbing✅ Implemented — logging standardized on log/slog (ADR-026); zerolog removed, enforced by the adr026-slog-only check
A10: Server-Side Request ForgeryURL validation, allowlist⚠️ Review per feature
  • Defense in Depth: Multiple layers of security controls
  • Compliance Ready: OWASP Top 10 coverage enables easier audits
  • Reduced Risk: Proactive threat modeling prevents vulnerabilities
  • User Trust: Strong security posture builds confidence
  • Development Overhead: Security checks add complexity
  • Performance Impact: Rate limiting and validation add latency
  • Maintenance Burden: Security requires ongoing attention
  • False Security: Following patterns doesn’t guarantee security
  • Over-engineering: Excessive controls can harm usability
  • Alert Fatigue: Too many security logs can obscure real threats
  • Rejected: Never trust client-side validation alone
  • Rejected: Exposes application to abuse and DoS attacks
  • Rejected: JWT + Supabase Auth provides better UX and security

Amended 2026-09-30 (docs health audit; append-only per ADR-033): this checklist is the original plan, kept unticked as history; everything it lists shipped. Current status lives in the Enforcement section and the guides under docs/guides/.

  • Implement authentication middleware with JWT validation
  • Add CSRF protection to all state-changing endpoints
  • Implement rate limiting for authentication and API endpoints
  • Add security headers middleware
  • Create input validation helpers (email, slug, etc.)
  • Set up log scrubbing for sensitive fields
  • Configure govulncheck in CI pipeline
  • Document secrets rotation process
  • Conduct security review of all handlers
  • Perform penetration testing in staging

Review Date: 2026-02-15 (Quarterly security review)

Reviewed: 2026-09-30 (docs health audit — decision unchanged; next review 2027-03-31)


Date: 2025-11-15 Author: System Architecture Team

  • Testable consequences:
    • TC-1: Security headers (X-Frame-Options, nosniff, CSP; HSTS in production only) are set by middleware.
    • TC-2: No known-vulnerable dependencies.
    • TC-3: Queries are parameterized — no hand-written SQL in handlers.
  • Checks:
    • TC-1 → internal/middleware/security_test.go via task ci (status: block, pre-existing)
    • TC-2 → task scan:vuln (govulncheck) in task ci (status: block, pre-existing)
    • TC-3 → sqlc by construction + adr003-no-sql-in-handlers in scripts/adrcheck (status: block since 2026-09-30; owned by ADR-003)
  • Not machine-checkable: The OWASP table in this ADR honestly records partially-implemented mitigations (e.g. the §4 per-user/per-email rate-limit tiers in A07) — those are implementation gaps, not check gaps; a check can’t gate what doesn’t exist. Rate-limit tier values, secret rotation cadence, and log-scrubbing coverage are ops/review territory.
  • Graduation log: (empty)
  • Amendment 2026-08-19 (#91): the “no hardcoded secrets” rule shared with ADR-015 gained a content-level check — adr015-no-hardcoded-secrets in scripts/adrcheck (status: block since 2026-09-30; owned by ADR-015 TC-3).
  • Amendment 2026-09-30 (#145): TC-4 — the module graph verifies against go.sum (§8 “Run go mod verify in CI”) → go mod verify as a leg of task ci (status: block, tooling; it was mandated here since 2025 but never wired until this date).