ADR-014: Security Patterns and Threat Model
Status
Section titled “Status”Accepted (amended by ADR-025 §1, ADR-027 §4, ADR-028 §6, ADR-034 §4)
Context
Section titled “Context”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.
Decision
Section titled “Decision”1. Authentication and Authorization
Section titled “1. Authentication and Authorization”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-tokencookie and the refresh token is used exactly once, during guest→registered upgrade (ADR-024). Session carriage is amended by ADR-025 §3.
Authentication Strategy
Section titled “Authentication Strategy”- 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.
Authorization Strategy
Section titled “Authorization Strategy”- 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 middlewarefunc 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)) })}2. Input Validation and Sanitization
Section titled “2. Input Validation and Sanitization”Amended 2026-09-30 (docs health audit; append-only per ADR-033): the
bluemondaysample is illustrative; the app ships no HTML sanitiser. templ escapes every interpolated value and inputs are length-validated ininternal/validate.
Validation Principles
Section titled “Validation Principles”- 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
Input Validation Patterns
Section titled “Input Validation Patterns”// Validation helper functionsfunc 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 examplefunc 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...}SQL Injection Prevention
Section titled “SQL Injection Prevention”- 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 queryfunc 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 vulnerabilityfunc GetPostUnsafe(slug string) (*Post, error) { query := "SELECT * FROM posts WHERE slug = '" + slug + "'" // Attacker can inject: ' OR '1'='1 // ...}3. CSRF Protection
Section titled “3. CSRF Protection”Amended 2026-09-30 (docs health audit; append-only per ADR-033):
gorilla/csrfis not a dependency. The double-submit cookie is hand-rolled ininternal/middleware/csrf.go: 32-byte random token in an HttpOnly cookie (12h), sent back via theX-CSRF-Tokenheader (hx-headerson<body>) or thecsrf_tokenfield (components.CSRFField), compared in constant time. No per-session binding, no rotation.
CSRF Token Strategy
Section titled “CSRF Token Strategy”- 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 middlewarefunc 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 helperfunc renderForm(w http.ResponseWriter, r *http.Request) { tmpl.Execute(w, map[string]interface{}{ "CSRFToken": csrf.Token(r), })}Template Usage
Section titled “Template Usage”<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>4. Rate Limiting
Section titled “4. Rate Limiting”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_HEADERare set (ADR-027). Shipped tiers (internal/server/server.go): global 50 req/s burst 10;/authcredential POSTs 5/min;/learn30/min burst 20; the/patternsrate-limit demo 3 then one per 2s. The per-user and per-email tiers listed below are not implemented (OWASP table, A07). Every 429 carriesRetry-After(ADR-034).
Rate Limit Strategy
Section titled “Rate Limit Strategy”- 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 middlewarefunc 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 limitingfunc 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) })}5. Secrets Management
Section titled “5. Secrets Management”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-secretsscans shipped source and config intask check:adr.
Development Environment
Section titled “Development Environment”- Use
.envfiles loaded viagodotenv(as defined in ADR-001) - NEVER commit
.envto version control - Provide
.env.examplewith placeholder values
Production Environment
Section titled “Production Environment”- 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 environmentfunc 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 secretsconst ( DatabaseURL = "postgres://user:password@localhost/db" APIKey = "sk_live_abc123")6. Security Headers
Section titled “6. Security Headers”Required HTTP Headers
Section titled “Required HTTP Headers”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) })}7. Sensitive Data Handling
Section titled “7. Sensitive Data Handling”Logging Security
Section titled “Logging Security”- NEVER log passwords, tokens, or API keys
- NEVER log full credit card numbers (PCI DSS)
- Scrub sensitive fields from logs automatically
// Log scrubbing examplefunc 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}Database Encryption
Section titled “Database Encryption”- Encrypt sensitive columns at rest (e.g., PII, payment info)
- Use PostgreSQL
pgcryptoextension for column-level encryption - Store encryption keys separately from database
8. Dependency Security
Section titled “8. Dependency Security”Supply Chain Security
Section titled “Supply Chain Security”- Pin exact dependency versions in
go.mod - Run
go mod verifyin 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 verifyOWASP Top 10 Coverage
Section titled “OWASP Top 10 Coverage”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).
| Threat | Mitigation | Status |
|---|---|---|
| A01: Broken Access Control | RLS + 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 Failures | HTTPS, encrypted secrets, JWT | ✅ Implemented (TLS terminates at the edge per ADR-025) |
| A03: Injection | Parameterized queries, input validation | ✅ Implemented |
| A04: Insecure Design | Threat modeling, secure defaults | ✅ Implemented |
| A05: Security Misconfiguration | Security 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 Components | Dependency scanning, pinning | ✅ Implemented |
| A07: Authentication Failures | Rate 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 Failures | CSRF tokens, signed requests | ✅ Implemented — double-submit cookie CSRF middleware (internal/middleware/csrf.go, §3) applied globally in the server middleware stack |
| A09: Logging Failures | Structured logging, log scrubbing | ✅ Implemented — logging standardized on log/slog (ADR-026); zerolog removed, enforced by the adr026-slog-only check |
| A10: Server-Side Request Forgery | URL validation, allowlist | ⚠️ Review per feature |
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- 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
Negative
Section titled “Negative”- 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
Alternatives Considered
Section titled “Alternatives Considered”1. Client-Side Validation Only
Section titled “1. Client-Side Validation Only”- Rejected: Never trust client-side validation alone
2. No Rate Limiting
Section titled “2. No Rate Limiting”- Rejected: Exposes application to abuse and DoS attacks
3. Basic HTTP Authentication
Section titled “3. Basic HTTP Authentication”- Rejected: JWT + Supabase Auth provides better UX and security
Implementation Checklist
Section titled “Implementation Checklist”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
References
Section titled “References”- OWASP Top 10
- OWASP Cheat Sheet Series
- ADR-001: Foundation (Secrets management)
- ADR-004: Authorization Strategy (RLS)
- Go Security Best Practices
- Gorilla CSRF
Review Cadence
Section titled “Review Cadence”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
Enforcement
Section titled “Enforcement”- 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.goviatask ci(status: block, pre-existing) - TC-2 →
task scan:vuln(govulncheck) intask ci(status: block, pre-existing) - TC-3 → sqlc by construction +
adr003-no-sql-in-handlersinscripts/adrcheck(status: block since 2026-09-30; owned by ADR-003)
- TC-1 →
- 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-secretsinscripts/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 “Rungo mod verifyin CI”) →go mod verifyas a leg oftask ci(status: block, tooling; it was mandated here since 2025 but never wired until this date).