From 284251083bdfab66c7a5e399eec364845bf8ba25 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 21:20:13 +0200 Subject: [PATCH] docs(02): add phase research --- .../phases/02-authentication/02-RESEARCH.md | 1197 +++++++++++++++++ 1 file changed, 1197 insertions(+) create mode 100644 .planning/phases/02-authentication/02-RESEARCH.md diff --git a/.planning/phases/02-authentication/02-RESEARCH.md b/.planning/phases/02-authentication/02-RESEARCH.md new file mode 100644 index 0000000..882d889 --- /dev/null +++ b/.planning/phases/02-authentication/02-RESEARCH.md @@ -0,0 +1,1197 @@ +# Phase 2: Authentication - Research + +**Researched:** 2026-05-14 +**Domain:** Go web auth (sessions, argon2id, CSRF, rate limit) on chi + templ + pgx/v5 + sqlc + Postgres +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Database Schema — users** +- **D-01:** `users` columns: `id uuid PK default gen_random_uuid()`, `email citext NOT NULL UNIQUE`, `password_hash text NOT NULL`, `created_at timestamptz NOT NULL default now()`, `updated_at timestamptz NOT NULL default now()`. App normalizes email (lowercase + trim) on insert. Enable `citext` extension in the migration (`CREATE EXTENSION IF NOT EXISTS citext`). +- **D-02:** No `deleted_at` column. Hard delete only. +- **D-03:** No `email_verified_at` column. + +**Database Schema — sessions** +- **D-04:** `sessions` columns: `id text PK` (SHA-256 hex of opaque token), `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `created_at timestamptz NOT NULL default now()`, `expires_at timestamptz NOT NULL`. Indexes on `user_id` and `expires_at`. No UA / IP / last_seen. +- **D-05:** Cookie = raw 32-byte (crypto/rand) token, base64url-encoded. DB stores SHA-256(token) hex. +- **D-06:** Logout: `DELETE FROM sessions WHERE id = $1` and expire cookie (`Max-Age=0`). +- **D-07:** Lazy expiry — auth lookup includes `AND expires_at > now()`. GC job is Phase 6. + +**Password Hashing** +- **D-08:** **argon2id** via `golang.org/x/crypto/argon2`. Params: `t=1`, `m=64*1024`, `p=4`, salt 16B, hash 32B. Stored PHC string: `$argon2id$v=19$m=65536,t=1,p=4$$`. Self-test on package init. + +**Session Lifetime** +- **D-09:** Sliding 30 days. Extend `expires_at` ONLY when remaining < 7 days. +- **D-10:** Rotate session ID on every successful login and signup auto-login. +- **D-11:** Signup auto-logs in. No verification email. + +**Cookie Properties** +- **D-12:** Name TBD (planner picks). HttpOnly, Secure (gated by `ENV != "dev"`), SameSite=Lax, Path=/. `Max-Age` mirrors `expires_at`. +- **D-13:** Cookie value is the raw base64url token (no extra signing layer); DB lookup is source of truth. + +**CSRF** +- **D-14:** **`github.com/gorilla/csrf`** middleware, mounted AFTER `ResolveSession` and BEFORE POST/PUT/DELETE handlers. `csrf.Secure(ENV != "dev")` + `csrf.SameSite(csrf.SameSiteLaxMode)`. Reads `_csrf` form field or `X-CSRF-Token` header. +- **D-15:** Templ helper renders ``. Every form includes this component first. + +**Rate Limiting** +- **D-16:** Login: in-memory token bucket via `golang.org/x/time/rate`. Key: `lower(email) + ":" + clientIP`. 5/min, burst 5. `map[string]*rate.Limiter` + `sync.Mutex` (or `sync.Map`). Janitor goroutine evicts idle > 10 min. +- **D-17:** Client IP via `chimw.RealIP` already in stack; read `r.RemoteAddr` post-middleware. IP not persisted. +- **D-18:** 429 + HTMX-swapped inline error fragment. Login only — signup NOT rate-limited in v1. + +**Auth Pages UX** +- **D-19:** Routes: `GET /login`, `POST /login`, `GET /signup`, `POST /signup`, `POST /logout`. GET pages = full templ; POST errors = HTMX fragment; success = `HX-Redirect: /` for HTMX, `303 See Other` `Location: /` for non-HTMX. +- **D-20:** Login error generic: **"Invalid email or password"** for both unknown email AND wrong password. Validation errors (empty/malformed) ARE specific. +- **D-21:** Post-login redirect: always `/`. No `?next=`. +- **D-22:** Logout = POST form, button in base layout header when authed, CSRF-protected. + +**Protected Routes (chi)** +- **D-23:** `Group(func(r) { r.Use(RequireAuth) ... })` for protected routes. `RequireAuth` reads session from ctx (set by always-running `ResolveSession`), redirects unauth → `/login` (`303` or `HX-Redirect`). `RedirectIfAuthed` middleware wraps `GET /login` + `GET /signup` to bounce authed users → `/`. +- **D-24:** Middleware order: `RequestID → RealIP → SlogLogger → Recoverer → ResolveSession → csrf.Protect → [route groups]`. + +**Password Policy** +- **D-25:** `net/mail.ParseAddress` for email. Password length 12–128. No complexity rules. + +**Testing** +- **D-26:** Real DB tests. testcontainers-go OR compose Postgres via `TEST_DATABASE_URL` — planner picks. Reduced-cost argon2 params in unit tests. + +### Claude's Discretion +- Package layout: `internal/auth/` vs `internal/auth/` + `internal/session/`. +- Final cookie name, session token length (24–48 bytes), argon2 sub-param tuning if benchmarks show baseline is slow. +- Login/signup HTML look (minimal, consistent with `internal/web/ui` Button/Card). +- Whether to add a `flash` helper for post-logout "you have been logged out". +- Rate-limiter file/structure; must be unit-testable with injectable clock. +- CSRF auth-key wiring: planner names env var (e.g. `SESSION_SECRET` or `CSRF_KEY`); MUST come from env. +- Logout on protected group vs lenient public. Default: protected. + +### Deferred Ideas (OUT OF SCOPE) +- Password reset / forgot-password. +- Email verification. +- OAuth / social login. +- Magic-link login. +- MFA / TOTP / passkeys / WebAuthn. +- Account settings page. +- Session-list "active devices" UI. +- DB-backed rate limiting. +- Captcha. +- `?next=` deep-link return-to. +- Session GC sweep (→ Phase 6). +- Email redaction in error logs (→ Phase 7). + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| AUTH-01 | User can sign up with email and password (server-side validation, argon2 hash) | Standard Stack (argon2), Pattern: PHC encode/decode, Pitfall: argon2 cost in tests, V5 Input Validation | +| AUTH-02 | User can log in and receives a server-managed session | Pattern: createSession (SHA-256 token rotation), Pattern: cookie issuance | +| AUTH-03 | Sessions persist via HTTP-only, signed cookies (Secure + SameSite=Lax) and survive refresh | Pattern: http.SetCookie, D-12 cookie attributes | +| AUTH-04 | User can log out from any authenticated page (server invalidates session) | Pattern: DELETE session row + Max-Age=0 cookie | +| AUTH-05 | Protected routes redirect unauth → /login; authed users on /login → / | Pattern: chi Group + RequireAuth + RedirectIfAuthed; HX-Redirect vs 303 | +| AUTH-06 | CSRF protection on all state-changing requests | Standard Stack (gorilla/csrf v1.7.3), Pattern: csrf.Protect mounting, body-consumption pitfall | +| AUTH-07 | Rate-limited login attempts per email + IP | Standard Stack (golang.org/x/time/rate v0.15.0), Pattern: per-key Limiter map + janitor, injectable clock | + + +## Project Constraints (from CLAUDE.md) + +The Go-rewrite section locks: Go + HTMX + Tailwind + Postgres + sqlc — **no third-party auth, no JS framework, no managed BaaS**. Server-managed sessions only (HTTP-only cookies); **no JWTs from external providers**. Single VPS / single binary deploy. GSD workflow enforcement: phase work goes through `/gsd-execute-phase`. + +Phase 2 specifically: **no external auth SDK**, **no JWT library** (e.g. `jose`/`golang-jwt`), **no managed session-store service** (e.g. Redis is permitted in later phases but is not introduced in Phase 2 — sessions live in Postgres). + +## Summary + +Phase 2 layers seven well-understood security primitives (password hashing, session cookies, CSRF, rate limit, route-group access control, HTMX flow, real DB tests) onto the Phase 1 chi + templ + pgxpool walking skeleton. Every architectural decision is already locked in CONTEXT.md (D-01 through D-26). This research locks concrete API signatures, ordering rules, and gotchas the planner needs to write correct PLAN.md files **without inventing new approaches**. + +The trickiest piece is **gorilla/csrf**, which consumes the request body when reading the `_csrf` form field — handlers must call `csrf.Token(r)` AFTER the middleware has run, and ordering relative to `ResolveSession` is load-bearing. The argon2id PHC encode/decode round-trip has no stdlib helper; the planner must reference the canonical alex-edwards pattern (or vendor the thin wrapper). Rate-limit testing requires an injectable `time.Time` source via `rate.NewLimiter` + `AllowN(t, n)` rather than `Allow()`. + +**Primary recommendation:** Build the vertical slice in this order, mirroring the locked decisions: +1. **Wave 1 — Schema + sqlc.** Migration `0002_auth.sql` (citext extension + users + sessions). `internal/db/queries/users.sql` + `sessions.sql`. `sqlc generate`. **Critical:** sqlc emits `pgtype.Text` for `citext` unless overridden — add an `overrides` block in `sqlc.yaml` so `citext` maps to Go `string`. +2. **Wave 2 — Password package** (`internal/auth/password.go`): argon2id PHC encode + decode + constant-time verify. Self-test in `init()`. Reduced-cost params via env override for tests. +3. **Wave 3 — Session package + middleware** (`internal/auth/session.go` + `internal/web/middleware_auth.go`): token generate / hash / store / lookup / rotate / delete; `ResolveSession`, `RequireAuth`, `RedirectIfAuthed` middleware; cookie helpers. +4. **Wave 4 — Signup flow end-to-end** (handler + templ + e2e test): validate → hash → insert user → createSession → set cookie → HX-Redirect /. +5. **Wave 5 — Login flow end-to-end** (handler + templ + rate limit): rate check → lookup user → verify hash → rotate session → set cookie → HX-Redirect /. +6. **Wave 6 — Logout + protected routes**: POST /logout handler + base-layout button + protect home route. +7. **Wave 7 — CSRF integration**: mount csrf.Protect, add `CSRFField()` templ helper, wire into every form. + +Each wave is testable end-to-end against a real Postgres before the next starts. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Password hashing & verify | Go server (`internal/auth/password.go`) | — | CPU-bound; argon2id never leaves the server process | +| Session token generation | Go server (`crypto/rand` in `internal/auth/session.go`) | — | Single source for entropy + SHA-256 hashing | +| Session storage | Postgres (`sessions` table via pgx) | — | Durable, single-host-friendly, no Redis dep | +| Session cookie issuance | Go server (`http.SetCookie`) | Browser (stores HttpOnly cookie) | Cookie attributes set server-side; browser opaquely echoes | +| Session resolution per request | Go server (`ResolveSession` middleware) | — | Reads cookie → SHA-256 → SELECT row → ctx-attach | +| Access control (auth gate) | Go server (`RequireAuth` middleware) | — | All gates run server-side; client cannot bypass via JS | +| CSRF token issuance + verification | Go server (`gorilla/csrf` middleware) | Browser (returns token in form / X-CSRF-Token) | Double-submit pattern owned by lib | +| Rate limiting | Go server (`golang.org/x/time/rate` in-mem) | — | Single-binary scope; no shared store needed | +| Login form rendering | Go server (templ) | Browser (HTMX form submit) | Server renders; HTMX swaps error fragments | +| Email normalization | Go server (`strings.ToLower` + `TrimSpace` on insert) | Postgres (`citext` enforces case-insensitive uniqueness) | App canonicalizes; DB enforces | +| Email validation | Go server (`net/mail.ParseAddress`) | — | Stdlib; no extra dep | +| Post-login redirect | Go server (303 or `HX-Redirect`) | Browser (HTMX honors header) | Server decides target | + +## Standard Stack + +### Core (new in Phase 2) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `golang.org/x/crypto/argon2` | v0.51.0 (from `golang.org/x/crypto`) | argon2id hashing | OWASP 2024 baseline; only canonical Go implementation [VERIFIED: `go list -m -versions golang.org/x/crypto` → latest v0.51.0 on 2026-05-14] | +| `github.com/gorilla/csrf` | v1.7.3 | CSRF middleware | Battle-tested; only mature chi-compatible CSRF lib [VERIFIED: `go list -m -versions` → v1.7.3 latest] | +| `golang.org/x/time/rate` | v0.15.0 (from `golang.org/x/time`) | Token-bucket rate limiter | Stdlib-adjacent; canonical Go rate limiter [VERIFIED: `go list -m -versions golang.org/x/time` → v0.15.0 latest] | +| `crypto/rand` | stdlib | Cryptographic random for session tokens | Required for D-05 32-byte tokens [CITED: pkg.go.dev/crypto/rand] | +| `crypto/subtle` | stdlib | Constant-time hash comparison | Required to prevent timing attacks on argon2 verify [CITED: pkg.go.dev/crypto/subtle] | +| `crypto/sha256` | stdlib | Hash session token before DB write | D-05 defense-in-depth [CITED: pkg.go.dev/crypto/sha256] | +| `encoding/base64` | stdlib | base64url session token + argon2 salt/hash encode | PHC + cookie formats [CITED: pkg.go.dev/encoding/base64] | +| `net/mail` | stdlib | Email validation (`ParseAddress`) | D-25; no external dep needed [CITED: pkg.go.dev/net/mail] | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `github.com/testcontainers/testcontainers-go` + `/modules/postgres` | v0.42.0 | Hermetic Postgres for integration tests | If planner picks testcontainers path (D-26); see Testing Strategy below for recommendation [VERIFIED: `go list -m -versions` → v0.42.0 latest] | +| `github.com/alexedwards/argon2id` | v1.0.0 (optional) | Thin wrapper over `x/crypto/argon2` with PHC encode/decode/verify | Optional shortcut — saves ~80 LOC; trade-off discussed below [CITED: pkg.go.dev/github.com/alexedwards/argon2id] | + +### Alternatives Considered (rejected per CONTEXT.md) +| Instead of | Could Use | Tradeoff / Why Rejected | +|------------|-----------|--------------------------| +| argon2id | bcrypt (`golang.org/x/crypto/bcrypt`) | User explicitly chose argon2id over bcrypt (D-08); bcrypt has 72-byte input cap | +| gorilla/csrf | Hand-rolled double-submit cookie | Reinvents subtle library work (token rotation, header/form fallback) — D-14 locks gorilla/csrf | +| `x/time/rate` | `uber-go/ratelimit`, `redis_rate` | Token bucket is the right algorithm per D-16; uber-go is leaky bucket; redis_rate adds an infra dep | +| Postgres session store | Redis, encrypted cookies (Lucia-style) | Single-host v1; Postgres avoids new infra (PROJECT.md "Postgres only") | +| `alexedwards/argon2id` wrapper | Hand-rolled PHC encode/decode | Wrapper saves code but pins another dep; D-08 says "code lives in `internal/auth/password.go`" — slight lean toward hand-rolled per CONTEXT.md self-test phrasing. Either is acceptable; planner picks. | +| testcontainers-go | compose Postgres + `TEST_DATABASE_URL` | D-26 leaves to planner — see recommendation below | + +**Installation:** +```bash +cd backend +go get golang.org/x/crypto/argon2@v0.51.0 +go get github.com/gorilla/csrf@v1.7.3 +go get golang.org/x/time/rate@v0.15.0 +# Optional: +go get github.com/testcontainers/testcontainers-go@v0.42.0 +go get github.com/testcontainers/testcontainers-go/modules/postgres@v0.42.0 +# Optional argon2 wrapper (if planner picks): +go get github.com/alexedwards/argon2id@v1.0.0 +``` + +**Version verification:** Versions above were verified via `go list -m -versions` on 2026-05-14 inside `/tmp`. Re-verify with `go list -m -u ` before pinning if execution slips past 2026-06-14. + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ GET /login ──▶ HTML form (CSRF hidden input) │ +│ POST /login ──▶ form-encoded (email, password, _csrf) │ +│ [HX-Request: true if HTMX] │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ chi middleware chain (locked order D-24) │ +│ RequestID → RealIP → SlogLogger → Recoverer │ +│ → ResolveSession (reads cookie, hashes, SELECT session+user, │ +│ attaches to ctx; never blocks) │ +│ → csrf.Protect (consumes form body; validates _csrf or │ +│ X-CSRF-Token on POST/PUT/PATCH/DELETE) │ +│ → [route group] │ +│ public: GET /login, GET /signup, POST /login, POST /signup│ +│ + RedirectIfAuthed wraps GET /login, GET /signup │ +│ protected: GET /, POST /logout │ +│ + RequireAuth wraps the group │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ POST /login handler │ +│ 1. parse form (email, password) │ +│ 2. validate (net/mail.ParseAddress, len(pw) 12–128) │ +│ 3. rate.limiter.AllowN(now, 1) on key = lower(email)+":"+ip │ +│ ─ false: 429 + HTMX fragment "Too many attempts..." │ +│ 4. SELECT users WHERE email = ? (citext compares ci) │ +│ ─ not found: render generic "Invalid email or password" │ +│ 5. password.Verify(stored_phc, supplied_pw) [constant-time] │ +│ ─ false: render generic "Invalid email or password" │ +│ 6. session.Create(userID): │ +│ - 32 bytes from crypto/rand │ +│ - cookie value = base64url(token) │ +│ - DB id = hex(sha256(token)) │ +│ - INSERT sessions (id, user_id, expires_at = now+30d) │ +│ 7. http.SetCookie(...) with HttpOnly/Secure/SameSite=Lax/MaxAge │ +│ 8. if HX-Request: w.Header().Set("HX-Redirect", "/") │ +│ else: http.Redirect(w, r, "/", 303) │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ POST /logout handler (protected, CSRF-checked) │ +│ 1. session.Delete(sessID from ctx) → DELETE FROM sessions │ +│ 2. http.SetCookie(..., MaxAge: -1) ─ expire immediately │ +│ 3. HX-Redirect: /login OR 303 → /login │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Postgres (single host, single DB) │ +│ users (id, email citext UNIQUE, password_hash, ...) │ +│ sessions (id text PK [=hex sha256], user_id, created_at, │ +│ expires_at, idx user_id, idx expires_at) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure + +``` +backend/ +├── internal/ +│ ├── auth/ # NEW in Phase 2 +│ │ ├── doc.go +│ │ ├── password.go # argon2id Hash / Verify + PHC encode/decode +│ │ ├── password_test.go # round-trip, wrong-password, malformed PHC +│ │ ├── session.go # Create / Lookup / Delete / Rotate / Extend +│ │ ├── session_test.go # touches Postgres (real DB test) +│ │ ├── ratelimit.go # per-key limiter map + janitor + injectable clock +│ │ ├── ratelimit_test.go # injectable now() for AllowN(t, 1) determinism +│ │ ├── cookie.go # SessionCookie / clear / read helpers +│ │ └── csrf.go # gorilla/csrf wiring + templ field helper +│ ├── session/ # absorb the Phase 1 placeholder into internal/auth OR +│ │ # keep as a thin re-export — planner picks +│ ├── db/queries/ +│ │ ├── users.sql # InsertUser, GetUserByEmail +│ │ └── sessions.sql # InsertSession, GetSession, DeleteSession, +│ │ # DeleteSessionsByUser, ExtendSession +│ └── web/ +│ ├── middleware_auth.go # ResolveSession, RequireAuth, RedirectIfAuthed +│ ├── handlers_auth.go # signup, login, logout handlers +│ └── handlers_auth_test.go # integration tests (real DB) +├── templates/ +│ ├── auth_login.templ # full page (Layout + form) +│ ├── auth_signup.templ +│ ├── auth_form_errors.templ # HTMX fragment for POST error swaps +│ └── layout.templ # extend with conditional logout button +└── migrations/ + └── 0002_auth.sql # citext + users + sessions +``` + +### Pattern 1: argon2id Hash + Verify with PHC encoding + +**What:** Hash a password to a self-describing PHC string and verify in constant time. +**When to use:** `auth.Hash(password) (string, error)` and `auth.Verify(phc, password) (bool, error)` — the two only entry points the handlers call. +**Source:** [alexedwards.net argon2 tutorial](https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go) (canonical pattern); [pkg.go.dev/golang.org/x/crypto/argon2](https://pkg.go.dev/golang.org/x/crypto/argon2). + +```go +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +type Params struct { + Memory uint32 // KiB + Iterations uint32 + Parallelism uint8 + SaltLength uint32 + KeyLength uint32 +} + +// DefaultParams: OWASP 2024 baseline (D-08). +var DefaultParams = Params{ + Memory: 64 * 1024, Iterations: 1, Parallelism: 4, + SaltLength: 16, KeyLength: 32, +} + +// TestParams: reduced-cost variant for `go test`. Same code path, ~10x faster. +var TestParams = Params{ + Memory: 8 * 1024, Iterations: 1, Parallelism: 2, + SaltLength: 16, KeyLength: 32, +} + +var ( + ErrInvalidHash = errors.New("auth: hash is not in PHC format") + ErrIncompatibleVersion = errors.New("auth: incompatible argon2 version") +) + +func Hash(password string, p Params) (string, error) { + salt := make([]byte, p.SaltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + h := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, p.Memory, p.Iterations, p.Parallelism, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(h), + ), nil +} + +func Verify(encoded, password string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, ErrInvalidHash + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return false, err + } + if version != argon2.Version { + return false, ErrIncompatibleVersion + } + var mem, iter uint32 + var par uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &iter, &par); err != nil { + return false, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return false, err + } + want, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) + if err != nil { + return false, err + } + got := argon2.IDKey([]byte(password), salt, iter, mem, par, uint32(len(want))) + return subtle.ConstantTimeCompare(want, got) == 1, nil +} +``` + +**Notes:** +- `argon2.Version` is `0x13` (`19` decimal) — always store in PHC and reject mismatches on verify. +- `subtle.ConstantTimeCompare` returns `1` on equal, `0` otherwise. **Never use `bytes.Equal`** for hash compare. +- `base64.RawStdEncoding` (no padding) is the PHC convention; `.Strict()` rejects malformed input. +- Self-test in `init()`: hash a fixed password, verify it, panic if false — catches accidental param drift. + +### Pattern 2: Session token — generate, hash, store, lookup + +**What:** Opaque token in cookie, SHA-256 hash in DB. Defense in depth against DB-read leaks (D-05). + +```go +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "time" + + "github.com/google/uuid" +) + +const SessionTTL = 30 * 24 * time.Hour +const SessionExtendThreshold = 7 * 24 * time.Hour + +// Create generates a fresh token, inserts a session row, returns the cookie value. +func (s *Store) Create(ctx context.Context, userID uuid.UUID) (cookieValue string, expiresAt time.Time, err error) { + raw := make([]byte, 32) + if _, err = rand.Read(raw); err != nil { + return "", time.Time{}, err + } + cookieValue = base64.RawURLEncoding.EncodeToString(raw) + id := hex.EncodeToString(sha256.New().Sum(raw)) // see note + // Correct form: + sum := sha256.Sum256(raw) + id = hex.EncodeToString(sum[:]) + expiresAt = time.Now().Add(SessionTTL) + err = s.q.InsertSession(ctx, sqlc.InsertSessionParams{ID: id, UserID: userID, ExpiresAt: expiresAt}) + return +} + +// Lookup hashes the cookie value and resolves user+session (D-07 lazy expiry). +func (s *Store) Lookup(ctx context.Context, cookieValue string) (*Session, *User, error) { + raw, err := base64.RawURLEncoding.DecodeString(cookieValue) + if err != nil || len(raw) != 32 { + return nil, nil, ErrSessionNotFound + } + sum := sha256.Sum256(raw) + id := hex.EncodeToString(sum[:]) + row, err := s.q.GetSessionWithUser(ctx, id) // includes AND expires_at > now() + if err != nil { return nil, nil, ErrSessionNotFound } + return rowToSession(row), rowToUser(row), nil +} + +// Delete is logout. +func (s *Store) Delete(ctx context.Context, id string) error { + return s.q.DeleteSession(ctx, id) +} + +// Rotate: delete old, create new — used on login + signup (D-10). +func (s *Store) Rotate(ctx context.Context, oldID string, userID uuid.UUID) (string, time.Time, error) { + if oldID != "" { _ = s.q.DeleteSession(ctx, oldID) } + return s.Create(ctx, userID) +} + +// Extend only when remaining < 7d (D-09). Called from ResolveSession on every authed request. +func (s *Store) MaybeExtend(ctx context.Context, id string, expiresAt time.Time) error { + if time.Until(expiresAt) >= SessionExtendThreshold { return nil } + return s.q.ExtendSession(ctx, sqlc.ExtendSessionParams{ID: id, ExpiresAt: time.Now().Add(SessionTTL)}) +} +``` + +**Pitfall:** the snippet above shows the WRONG `sha256.New().Sum(raw)` first (do not use), then the CORRECT `sha256.Sum256(raw)`. `sha256.New().Sum(b)` *appends* the digest to `b`. Use `sha256.Sum256` to get just the digest. + +### Pattern 3: Cookie issuance (D-12) + +```go +func setSessionCookie(w http.ResponseWriter, name, value string, expiresAt time.Time, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + Expires: expiresAt, + MaxAge: int(time.Until(expiresAt).Seconds()), + HttpOnly: true, + Secure: secure, // ENV != "dev" + SameSite: http.SameSiteLaxMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter, name string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", + Expires: time.Unix(0, 0), MaxAge: -1, + HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, + }) +} +``` + +Note: `MaxAge < 0` deletes the cookie immediately; `MaxAge == 0` means "no Max-Age attribute" (session cookie). Always use `-1` for logout per [pkg.go.dev/net/http#Cookie](https://pkg.go.dev/net/http#Cookie). + +### Pattern 4: ResolveSession middleware (always runs; never blocks) + +```go +type sessionCtxKey struct{} + +func ResolveSession(store *auth.Store, cookieName string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + next.ServeHTTP(w, r); return + } + sess, user, err := store.Lookup(r.Context(), c.Value) + if err != nil { + next.ServeHTTP(w, r); return // do NOT clear the cookie here — handler decides + } + _ = store.MaybeExtend(r.Context(), sess.ID, sess.ExpiresAt) // best-effort + ctx := context.WithValue(r.Context(), sessionCtxKey{}, &authedRequest{sess, user}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func authed(ctx context.Context) (*Session, *User, bool) { + a, ok := ctx.Value(sessionCtxKey{}).(*authedRequest) + if !ok { return nil, nil, false } + return a.session, a.user, true +} +``` + +### Pattern 5: RequireAuth / RedirectIfAuthed + HTMX-aware redirect + +```go +func RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := authed(r.Context()); !ok { + redirect(w, r, "/login"); return + } + next.ServeHTTP(w, r) + }) +} + +func RedirectIfAuthed(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := authed(r.Context()); ok { + redirect(w, r, "/"); return + } + next.ServeHTTP(w, r) + }) +} + +func redirect(w http.ResponseWriter, r *http.Request, target string) { + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", target) + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, target, http.StatusSeeOther) // 303 +} +``` + +**Why 303 (not 302) for non-HTMX:** RFC 7231 §6.4.4 — 303 forces the follow-up to be GET regardless of original method, which is what we want after POST /login. (302 leaves method up to the client.) + +### Pattern 6: chi route groups for auth + +```go +r := chi.NewRouter() +r.Use(RequestIDMiddleware, chimw.RealIP, SlogLoggerMiddleware(slog.Default()), chimw.Recoverer) +r.Use(ResolveSession(authStore, cookieName)) // D-24 +r.Use(csrf.Protect(csrfKey, csrf.Secure(env != "dev"), // D-14 + csrf.SameSite(csrf.SameSiteLaxMode), + csrf.Path("/"))) + +// Public (auth-aware) routes +r.Group(func(r chi.Router) { + r.Use(RedirectIfAuthed) + r.Get("/login", loginPageHandler) + r.Get("/signup", signupPageHandler) +}) +r.Post("/login", loginPostHandler) // body-consumed by csrf; see Pitfall 1 +r.Post("/signup", signupPostHandler) + +// Protected +r.Group(func(r chi.Router) { + r.Use(RequireAuth) + r.Get("/", indexHandler) + r.Post("/logout", logoutHandler) +}) + +// Demo + healthz stay public +r.Get("/healthz", HealthzHandler(pinger)) +r.Get("/demo/time", DemoTimeHandler(time.Now)) +r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) +``` + +**Note on chi `Group` vs `With`:** `Group(fn)` creates a sub-router that inherits parent middleware AND can add its own. `With(mw)` returns a router with added middleware for a single route declaration. **Use `Group` for the protected/public split** — clearer at the call site and matches D-23 phrasing. + +### Pattern 7: gorilla/csrf integration (D-14, D-15) + +**Source:** [github.com/gorilla/csrf](https://github.com/gorilla/csrf), [pkg.go.dev/github.com/gorilla/csrf](https://pkg.go.dev/github.com/gorilla/csrf). + +```go +import "github.com/gorilla/csrf" + +// In NewRouter (or main wiring): +csrfMW := csrf.Protect( + csrfKey, // 32 bytes from env + csrf.Secure(env != "dev"), + csrf.SameSite(csrf.SameSiteLaxMode), + csrf.Path("/"), + csrf.FieldName("_csrf"), // default but explicit per D-14 + csrf.RequestHeader("X-CSRF-Token"), // default — for future HTMX hx-headers usage +) +r.Use(csrfMW) +``` + +**Templ helper (D-15):** +```go +// internal/web/ui/csrf_field.templ +templ CSRFField(token string) { + +} + +// Handler passes token via templ component arg: +templates.LoginPage(csrf.Token(r)).Render(r.Context(), w) +``` + +**Auth key:** 32 bytes, from env (`SESSION_SECRET` or `CSRF_KEY`). On startup, hex- or base64-decode. **MUST be persistent across restarts** or all existing CSRF tokens (and any cookie-bound state) invalidate. + +### Pattern 8: Rate limiter (D-16, AUTH-07) + +**Source:** [pkg.go.dev/golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate); [Alex Edwards' "How to Rate Limit HTTP Requests in Go"](https://www.alexedwards.net/blog/how-to-rate-limit-http-requests). + +```go +package auth + +import ( + "sync" + "time" + + "golang.org/x/time/rate" +) + +type LimiterStore struct { + mu sync.Mutex + limits map[string]*entry + rate rate.Limit // 5 / minute = rate.Every(12 * time.Second) + burst int // 5 + idleTTL time.Duration + now func() time.Time // INJECTABLE for tests +} + +type entry struct { + lim *rate.Limiter + lastSeen time.Time +} + +func NewLimiterStore() *LimiterStore { + return &LimiterStore{ + limits: make(map[string]*entry), + rate: rate.Every(12 * time.Second), // 5/min + burst: 5, + idleTTL: 10 * time.Minute, + now: time.Now, + } +} + +func (s *LimiterStore) Allow(key string) bool { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.limits[key] + if !ok { + e = &entry{lim: rate.NewLimiter(s.rate, s.burst)} + s.limits[key] = e + } + e.lastSeen = s.now() + return e.lim.AllowN(s.now(), 1) // AllowN(t, 1) — t-injectable for tests +} + +// Janitor: call from a goroutine started in NewRouter or main. +func (s *LimiterStore) StartJanitor(interval time.Duration, stop <-chan struct{}) { + go func() { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-stop: return + case <-t.C: + s.mu.Lock() + cutoff := s.now().Add(-s.idleTTL) + for k, e := range s.limits { + if e.lastSeen.Before(cutoff) { delete(s.limits, k) } + } + s.mu.Unlock() + } + } + }() +} +``` + +**Why `AllowN(t, n)` over `Allow()`:** `Allow()` calls `time.Now()` internally — no way to inject. `AllowN(t time.Time, n int)` accepts a caller-supplied timestamp, which makes tests deterministic ("simulate 6 attempts at t, t+1s, ..., t+11s and assert the 6th is rejected"). This is the canonical Go-test pattern. + +**Login handler integration:** +```go +key := strings.ToLower(email) + ":" + clientIP(r) +if !rl.Allow(key) { + w.WriteHeader(http.StatusTooManyRequests) + templates.LoginErrorFragment("Too many attempts. Try again in a minute.").Render(r.Context(), w) + return +} +``` + +`clientIP(r)` reads `r.RemoteAddr` (post-`chimw.RealIP`, which already rewrites it from X-Forwarded-For). + +### Pattern 9: HTMX form responses (D-19) + +- `GET /login` → full page (`Layout` + `LoginForm`). +- `POST /login` (success) → if `HX-Request: true`, `w.Header().Set("HX-Redirect", "/")` then 200; else 303 + `Location: /`. +- `POST /login` (validation/auth error) → if HTMX, return **only the form fragment** with error injected, swap into `#login-form`. Without HTMX, re-render the full page with errors. + +```go +isHTMX := r.Header.Get("HX-Request") == "true" +if !isHTMX { + templates.LoginPage(token, formErrors).Render(r.Context(), w) + return +} +templates.LoginFormFragment(token, formErrors).Render(r.Context(), w) +``` + +The form has `hx-post="/login" hx-target="#login-form" hx-swap="outerHTML"`. + +### Pattern 10: sqlc + citext + +sqlc by default maps unknown PG types like `citext` to `interface{}` or `pgtype.Text`. Add an override in `sqlc.yaml`: + +```yaml +version: "2" +sql: + - engine: postgresql + schema: "migrations" + queries: "internal/db/queries" + gen: + go: + package: "sqlc" + out: "internal/db/sqlc" + sql_package: "pgx/v5" + emit_json_tags: false + emit_interface: false + overrides: + - db_type: "citext" + go_type: "string" + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" +``` + +The `uuid` override is necessary because sqlc-pgx defaults to `pgtype.UUID`; using `google/uuid.UUID` keeps handler code clean (the pgx UUID codec auto-converts). + +### Pattern 11: Migration file (D-01, D-04) + +```sql +-- migrations/0002_auth.sql +-- +goose Up +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for gen_random_uuid() + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL UNIQUE, + password_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE sessions ( + id text PRIMARY KEY, -- hex(sha256(token)) + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); +CREATE INDEX sessions_user_id_idx ON sessions(user_id); +CREATE INDEX sessions_expires_at_idx ON sessions(expires_at); + +-- +goose Down +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; +-- citext + pgcrypto left in place — extensions are cheap and other migrations may need them. +``` + +**Note on `gen_random_uuid()`:** Postgres 13+ includes it without `pgcrypto`, but Phase 1 uses `postgres:16-alpine`, so the explicit `CREATE EXTENSION IF NOT EXISTS pgcrypto` is redundant-but-safe. Keep it for older-PG portability. + +### Anti-Patterns to Avoid + +- **Storing the raw session token in the DB.** Violates D-05. Always SHA-256 before INSERT/SELECT. +- **Using `bytes.Equal` or `==` for hash comparison.** Timing leak. Use `subtle.ConstantTimeCompare`. +- **Hashing the password without a per-password salt.** argon2 won't catch this — `IDKey` accepts whatever salt you pass. Generate one fresh with `crypto/rand` per call. +- **Calling `csrf.Token(r)` before `csrf.Protect` runs.** Returns empty string. Templ helpers must receive the token from a handler that runs INSIDE the middleware chain. +- **Mounting `csrf.Protect` before `ResolveSession`.** CSRF cookie is independent but downstream handlers may want to skip CSRF for some pre-auth endpoints; ordering per D-24 keeps `ResolveSession` cheap and `csrf.Protect` second. +- **Using `Allow()` (no args) in the rate limiter.** Untestable. Use `AllowN(s.now(), 1)`. +- **Re-using the same `*rate.Limiter` across all keys.** That's a global limit, not per-key. Must map[key]*Limiter. +- **Calling `pool.Ping` in `NewPool`.** Phase 1 already establishes lazy connection — don't reintroduce eager dialing here. +- **Logging the email on failed login.** AUTH UX is "generic message"; logs should likewise avoid associating attempt counts with real emails until Phase 7 redaction lands. Log SHA-256(email) prefix or just "login_failed". +- **Forgetting to delete the old session row on rotate.** Defeats D-10 fixation defense. +- **Using `MaxAge: 0` to delete a cookie.** That means "no Max-Age attribute" (session cookie). Use `-1`. +- **Hardcoding the CSRF key.** Must come from env (`SESSION_SECRET` or `CSRF_KEY`). + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Password hashing | DIY argon2 or bcrypt wrapper | `golang.org/x/crypto/argon2` + the canonical PHC encode/decode pattern (or `alexedwards/argon2id`) | RFC 9106 compliance, side-channel-resistant Argon2id mode, official PHC format | +| Constant-time compare | `bytes.Equal` | `crypto/subtle.ConstantTimeCompare` | Timing-attack resistant | +| Random session tokens | `math/rand` + custom alphabet | `crypto/rand.Read` 32 bytes + `base64.RawURLEncoding` | Cryptographically secure entropy | +| CSRF tokens | Custom double-submit / per-form HMAC | `github.com/gorilla/csrf` | Token rotation, form/header/JSON support, masking, safe defaults | +| Rate limiting | Custom counter + ticker | `golang.org/x/time/rate.Limiter` | Token bucket, `AllowN(t, n)`, safe for concurrent use | +| Email parsing | Regex | `net/mail.ParseAddress` | RFC 5322-compliant; rejects bizarre cases regex misses | +| UUIDs | Custom random | `github.com/google/uuid` (already in go.mod) | Already vetted; matches Phase 1 request_id | +| Session-cookie attribute math | Custom Set-Cookie string | `http.SetCookie(&http.Cookie{...})` | Stdlib handles edge cases (encoding, attribute ordering) | +| Postgres test isolation | Custom truncate-all-tables | testcontainers-go `postgres.Snapshot` OR per-test schema | Snapshot is purpose-built; speedy | + +**Key insight:** Every primitive in Phase 2 has a canonical Go ecosystem solution. The only place to be inventive is the **integration shape** (the `Store` interface boundary, how `Session`+`User` flow through ctx, how HTMX vs non-HTMX submit paths share code). Inventiveness in the crypto primitives = bugs. + +## Runtime State Inventory + +> Phase 2 is a **greenfield** auth implementation. Phase 1 only introduced placeholder packages (`internal/session/doc.go`) and a no-op migration. There is no existing user data, no existing sessions, no rename or refactor. Section is informational only: + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | None — `users`/`sessions` tables don't exist yet (Phase 1 has only no-op `0001_init.sql`) | None | +| Live service config | None — no external auth service involved | None | +| OS-registered state | None — no OS-level auth registration | None | +| Secrets / env vars | New: `SESSION_SECRET` (or `CSRF_KEY`) — 32 random bytes hex/base64. Must be added to `.env.example` (placeholder value), `.env` (real value for dev), and CI/prod env injection. | Add env var; document in README; generate dev value via `openssl rand -hex 32` | +| Build artifacts | sqlc-generated code in `internal/db/sqlc/` will gain `users.sql.go` + `sessions.sql.go`. Run `just generate` after schema lands. | `just generate` after Wave 1 | + +## Common Pitfalls + +### Pitfall 1: gorilla/csrf consumes the request body +**What goes wrong:** Handler calls `r.ParseForm()` after csrf.Protect — `email` and `password` fields are present but the second read returns empty because the body was already consumed by the middleware to read `_csrf`. +**Why it happens:** csrf.Protect calls `r.FormValue("_csrf")` (or whatever field name is configured), which reads + caches `r.Form`. The cached `r.Form` IS available to handlers — but only via `r.FormValue("...")` / `r.PostFormValue(...)`, NOT by re-reading `r.Body`. +**How to avoid:** Handlers read form values exclusively via `r.PostFormValue("email")` etc. Never touch `r.Body` directly. (Verified via gorilla/csrf source and the chia-goths reference example.) +**Warning signs:** Empty email / password values in handlers after csrf is mounted. + +### Pitfall 2: CSRF token absent on GET-rendered forms +**What goes wrong:** `
` renders without the hidden `_csrf` input → every POST returns 403. +**Why it happens:** Handler forgot to pass `csrf.Token(r)` into the templ component. csrf.Protect requires the token on EVERY state-changing request. +**How to avoid:** Centralize via `CSRFField` templ component; lint check or unit test that every `` in any `.templ` has `@ui.CSRFField(...)` as its first child. Make the page-rendering helper take a `csrfToken string` arg (compile-time enforced presence). +**Warning signs:** Forbidden 403s on POST in dev. + +### Pitfall 3: citext column comparison via sqlc returns wrong type +**What goes wrong:** sqlc generates `Email pgtype.Text` for the citext column → handler code has to call `.String`/`.Valid` everywhere. +**Why it happens:** pgx doesn't have a built-in citext codec; sqlc treats unknown types as `pgtype.Text` (nullable string). +**How to avoid:** Add the `overrides: { db_type: "citext", go_type: "string" }` block to `sqlc.yaml` (Pattern 10 above). pgx's text codec serializes Go `string` to/from citext just fine because citext IS text on the wire — only the comparator differs. +**Warning signs:** Generated code has `Email pgtype.Text`; handler code has `.String` everywhere. + +### Pitfall 4: argon2 too slow in unit tests +**What goes wrong:** Each `Hash` call takes ~300ms; a test suite with 20 hashes wastes 6 seconds. +**Why it happens:** OWASP baseline (64 MiB memory) is intentionally slow. +**How to avoid:** Export `DefaultParams` and `TestParams` (Pattern 1). Production code passes `DefaultParams`; tests pass `TestParams`. The PHC format records params per-hash, so verification works seamlessly across test/prod hashes. +**Warning signs:** `go test` for `internal/auth` takes > 5 seconds. + +### Pitfall 5: Forgetting to rotate session on login +**What goes wrong:** Pre-login attacker fixates a session cookie on the victim; victim logs in; attacker uses the same cookie. +**Why it happens:** Tempting to "skip rotation if a valid session already exists". +**How to avoid:** D-10 is unambiguous: rotate ALWAYS on login + signup. The `Store.Rotate(oldID, userID)` helper deletes the old row before creating the new one — make this the ONLY way handlers create sessions on auth success. +**Warning signs:** Code path "if already has session, skip create_session". + +### Pitfall 6: SHA-256 mistake — `sha256.New().Sum(token)` instead of `sha256.Sum256(token)` +**What goes wrong:** Stored session ID is `token + zero_bytes` or `token + digest`, not the digest. Lookups never match. +**Why it happens:** `hash.Hash.Sum(b)` APPENDS the digest to `b` and returns the result; it does NOT compute the digest of `b`. +**How to avoid:** Use `sha256.Sum256(token)` which returns `[32]byte` (then `hex.EncodeToString(sum[:])`). +**Warning signs:** Every login appears to succeed but cookie validation never passes — sessions table fills up but lookups all miss. + +### Pitfall 7: chi middleware order around csrf.Protect +**What goes wrong:** `csrf.Protect` mounted before `ResolveSession`, OR mounted at the route-group level when route-group also installs RequireAuth → CSRF runs after auth fails → 401 vs 403 ambiguity. +**Why it happens:** Easy to reorder `r.Use` calls. +**How to avoid:** D-24 locks the order. Add a comment in `NewRouter` above each `r.Use` call referencing the locked order. +**Warning signs:** Logout returns 401 instead of 403 when missing token (or vice versa). + +### Pitfall 8: HTMX cookie path / Secure mismatch in dev +**What goes wrong:** Dev runs on `http://localhost:8080`. With `Secure: true`, the browser drops the cookie. User logs in, gets a 200, but next request has no cookie → redirected to /login again. +**Why it happens:** Forgot to gate `Secure` on `ENV != "dev"`. +**How to avoid:** Read `ENV` once at startup, pass `secure bool` through to all cookie-setting helpers and `csrf.Secure(secure)`. +**Warning signs:** Login appears to succeed but immediately bounces back to /login in local dev (browser DevTools shows "blocked: insecure"). + +### Pitfall 9: 302 vs 303 after POST +**What goes wrong:** Browser re-submits POST to `/` after login (some older browsers / curl `-L`). +**Why it happens:** `http.StatusFound` (302) leaves method behavior up to the client. +**How to avoid:** Use `http.StatusSeeOther` (303) — the spec mandates GET on follow-up. For HTMX, use `HX-Redirect` header which always triggers GET anyway. +**Warning signs:** Duplicate user inserts on signup; "method not allowed" on `/` after redirect. + +### Pitfall 10: testcontainers-go on macOS — slow startup +**What goes wrong:** First `TestSomething` takes 8+ seconds because the postgres container has to pull, start, and become ready. +**Why it happens:** Docker Desktop / Rancher Desktop on macOS proxies networking; ready-check needs to see the port forwarded. +**How to avoid:** Use the testcontainers-go `postgres` module which includes the correct double-readiness wait. For local dev iteration, prefer reusing the compose Postgres via `TEST_DATABASE_URL`; reserve testcontainers for CI or fresh-DB-per-test scenarios. (See Testing Strategy below.) +**Warning signs:** Test wall time >10s for trivial CRUD tests. + +### Pitfall 11: Rate-limiter memory leak +**What goes wrong:** Attacker hits `/login` with 100k distinct fake emails → map grows unboundedly. +**Why it happens:** No janitor. +**How to avoid:** D-16 mandates janitor goroutine; evict entries `lastSeen < now - 10min`. Stop the janitor on graceful shutdown. +**Warning signs:** Memory growth correlates with /login traffic in observability. + +### Pitfall 12: Forgetting `pgcrypto` for `gen_random_uuid()` +**What goes wrong:** Migration fails: "function gen_random_uuid() does not exist". +**Why it happens:** Forgot `CREATE EXTENSION IF NOT EXISTS pgcrypto`. On Postgres 13+ this is in core, but only if you're on `postgres:13-alpine` or later; older images don't have it. +**How to avoid:** Always include `CREATE EXTENSION IF NOT EXISTS pgcrypto` defensively. Postgres 13+ ignores re-creating. + +## Code Examples + +Verified patterns referenced inline above. Below are a few additional pieces the planner will need: + +### sqlc query file: `internal/db/queries/users.sql` +```sql +-- name: InsertUser :one +INSERT INTO users (email, password_hash) +VALUES ($1, $2) +RETURNING id, email, password_hash, created_at, updated_at; + +-- name: GetUserByEmail :one +SELECT id, email, password_hash, created_at, updated_at +FROM users +WHERE email = $1; +``` + +### sqlc query file: `internal/db/queries/sessions.sql` +```sql +-- name: InsertSession :exec +INSERT INTO sessions (id, user_id, expires_at) +VALUES ($1, $2, $3); + +-- name: GetSessionWithUser :one +SELECT s.id, s.user_id, s.created_at, s.expires_at, + u.id AS u_id, u.email, u.password_hash, u.created_at AS u_created_at, u.updated_at AS u_updated_at +FROM sessions s +JOIN users u ON u.id = s.user_id +WHERE s.id = $1 AND s.expires_at > now(); + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = $1; + +-- name: DeleteSessionsByUser :exec +DELETE FROM sessions WHERE user_id = $1; + +-- name: ExtendSession :exec +UPDATE sessions SET expires_at = $2 WHERE id = $1; +``` + +### Templ helpers (layout extension for logout button) + +```go +// templates/layout.templ — extend header +templ Layout(title string, user *auth.User, csrfToken string) { + ... +
+
+ Xtablo + if user != nil { + + @ui.CSRFField(csrfToken) + + + } +
+
+ ... +} +``` + +### Handler skeleton: `POST /login` + +```go +func loginPostHandler(store *auth.Store, rl *auth.LimiterStore, cookieName string, secure bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(strings.ToLower(r.PostFormValue("email"))) + password := r.PostFormValue("password") + + if _, err := mail.ParseAddress(email); err != nil { renderLoginError(w, r, "Email looks invalid"); return } + if len(password) < 12 || len(password) > 128 { renderLoginError(w, r, "Password must be 12–128 characters"); return } + + key := email + ":" + clientIP(r) + if !rl.Allow(key) { + w.WriteHeader(http.StatusTooManyRequests) + renderLoginError(w, r, "Too many attempts. Try again in a minute.") + return + } + + user, err := store.GetUserByEmail(r.Context(), email) + if err != nil { renderLoginError(w, r, "Invalid email or password"); return } + + ok, err := auth.Verify(user.PasswordHash, password) + if err != nil || !ok { renderLoginError(w, r, "Invalid email or password"); return } + + // Rotate any existing session, create new one + oldID := "" + if sess, _, ok := authed(r.Context()); ok { oldID = sess.ID } + cookieValue, expiresAt, err := store.Rotate(r.Context(), oldID, user.ID) + if err != nil { http.Error(w, "internal error", 500); return } + + setSessionCookie(w, cookieName, cookieValue, expiresAt, secure) + + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| bcrypt for new services | argon2id (RFC 9106) | OWASP 2024 cheatsheet update | argon2id is the recommended default for new applications | +| JWT in cookie | Opaque token + DB session row | Reaction to JWT-revocation pain (~2020+) | Server-controlled invalidation, smaller cookie | +| Raw token in DB | SHA-256(token) in DB | Lucia/Astro / "production-grade" guides (~2023+) | DB read leak ≠ live sessions | +| `SameSite=None` cookies | `SameSite=Lax` | Chrome default change 2020 | Block CSRF without breaking top-level GET nav | +| 302 redirect after POST | 303 See Other | Industry consensus | Force GET follow-up unambiguously | + +**Deprecated/outdated:** +- bcrypt for greenfield (still safe but suboptimal vs argon2id). +- MD5/SHA-1 for any password handling (broken). +- Custom CSRF implementations (use gorilla/csrf). +- `SameSite=Strict` for primary session cookies (breaks top-level GET nav from email links). + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `golang.org/x/crypto` latest is v0.51.0 (provides argon2 sub-pkg) | Standard Stack | LOW — verified via `go list -m -versions`. Re-verify if execution slips past 2026-06-14. | +| A2 | `gorilla/csrf` v1.7.3 supports the chi `Use(...)` mounting pattern and `csrf.Secure`/`csrf.SameSite` option signatures used in Pattern 7 | Standard Stack, Pattern 7 | LOW — verified via pkg.go.dev and the chia-goths example. | +| A3 | `golang.org/x/time/rate` v0.15.0 `AllowN(t, n)` API is unchanged from earlier versions | Standard Stack, Pattern 8 | LOW — API is stable since ~2018; only additions, no breaks. | +| A4 | Postgres 16 (image used in Phase 1 compose.yaml) supports `citext`, `pgcrypto`, and `gen_random_uuid()` | Pattern 11, Pitfall 12 | LOW — Postgres 13+ ships `gen_random_uuid()` in core; citext is universally available since 9.1. | +| A5 | pgx/v5 text codec handles `citext` columns transparently when sqlc emits Go `string` (per override) | Pattern 10, Pitfall 3 | MEDIUM — verified in pgx docs that text codec works for citext on the wire; planner should validate with a Wave 1 integration test before relying on this in handlers. | +| A6 | `csrf.Path("/")`, `csrf.FieldName("_csrf")` option signatures are stable in v1.7.x | Pattern 7 | LOW — confirmed via pkg.go.dev. | +| A7 | `testcontainers-go` v0.42.0 + the `postgres` module run cleanly under `podman` (not just Docker Desktop) on macOS/Linux. The developer's machine uses podman per Phase 1 D-11. | Testing Strategy | MEDIUM — testcontainers-go supports podman via `DOCKER_HOST=unix:///path/to/podman.sock` but it's documented as occasionally flaky. **This is a real risk** worth surfacing for the user — if podman+testcontainers doesn't work cleanly, planner should fall back to the compose-Postgres `TEST_DATABASE_URL` path. | + +**Interpretation:** A7 is the only assumption with non-trivial risk. Resolution is to recommend the `TEST_DATABASE_URL` path (reuse Phase 1's compose Postgres) as the default in Testing Strategy below, with testcontainers documented as the future fresh-DB-per-test option. + +## Open Questions + +1. **testcontainers-go vs compose Postgres for tests — recommendation** + - What we know: D-26 leaves it to the planner. Phase 1 already runs Postgres via `compose.yaml`. The developer uses podman. testcontainers-go has known podman friction. + - What's unclear: Whether testcontainers-go + the postgres module work cleanly with podman on the developer's machine. + - **Recommendation:** Use the **compose Postgres via `TEST_DATABASE_URL`** path for Phase 2: + - Reuse the existing `compose.yaml` Postgres (already running for `just dev`). + - Each test creates a unique schema (`CREATE SCHEMA test_; SET search_path TO test_;`) at setup, drops it at teardown — gives per-test isolation without container churn. + - Add `TEST_DATABASE_URL` to `.env.example` (planner picks default, e.g. `postgres://xtablo:xtablo@localhost:5432/xtablo_test?sslmode=disable`). + - Tests skip with `t.Skip` if `TEST_DATABASE_URL` is unset, mirroring the existing `internal/db/pool_test.go` pattern. + - **Why:** Avoids the podman+testcontainers compatibility risk (A7), avoids per-test container startup latency on macOS (~5–8s), and the developer already has the compose Postgres running during `just dev`. Reserve testcontainers-go as a Phase 7+ option (CI ephemerality, fresh-DB-per-test) once value/risk is clearer. + - **Status:** RECOMMENDED; planner may override if preferred. + +2. **`alexedwards/argon2id` wrapper vs hand-rolled — recommendation** + - What we know: D-08 phrasing ("hash code lives in `internal/auth/password.go` with a tiny self-test") leans hand-rolled. Wrapper saves ~80 LOC and provides identical PHC format. + - **Recommendation:** Hand-roll (Pattern 1 verbatim). The wrapper adds a dep we don't need; the code is short and easily testable; we want the self-test as `init()`-time invariant per CONTEXT.md. + - **Status:** RECOMMENDED hand-rolled. Planner may pick the wrapper if they prefer. + +3. **`internal/auth/` package layout — recommendation** + - What we know: D-71 (Claude's discretion) says planner picks; Phase 1 has an empty `internal/session/doc.go` placeholder. + - **Recommendation:** Consolidate everything into `internal/auth/` (password, session, ratelimit, cookie, csrf helpers). Delete `internal/session/` (or repurpose its `doc.go` to point at `internal/auth`). Single package = single import, easier to reason about. + - **Status:** RECOMMENDED. + +4. **Logout: protected group or lenient public?** + - What we know: D-73 (Claude's discretion). Default: protected. + - **Recommendation:** Protected (require auth to log out). Cleaner — unauth logouts are a no-op anyway; protected version gets free CSRF + auth context. The base-layout button only renders when authed, so the route is never reachable from an unauth page. + - **Status:** RECOMMENDED. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Phase 1 stack (Go, chi, templ, pgx, sqlc, goose, air, tailwind, htmx, podman, just) | Foundation already in place | ✓ | Per Phase 1 RESEARCH | — | +| `golang.org/x/crypto/argon2` | Password hashing | ✗ (not in go.mod yet) | v0.51.0 | `go get golang.org/x/crypto@v0.51.0` in Wave 2 | +| `github.com/gorilla/csrf` | CSRF middleware | ✗ | v1.7.3 | `go get github.com/gorilla/csrf@v1.7.3` in Wave 7 | +| `golang.org/x/time/rate` | Rate limiter | ✗ | v0.15.0 | `go get golang.org/x/time@v0.15.0` in Wave 5 | +| Postgres `citext` extension | users.email column | ✓ (Postgres 16-alpine ships it) | bundled | `CREATE EXTENSION IF NOT EXISTS citext` in migration | +| Postgres `pgcrypto` extension | `gen_random_uuid()` | ✓ (PG 13+ has core fallback) | bundled | `CREATE EXTENSION IF NOT EXISTS pgcrypto` defensive | +| Random source for `SESSION_SECRET` | CSRF auth key | ✓ (`openssl rand -hex 32`) | — | Document in README under "first-time setup" | +| testcontainers-go (optional) | Hermetic Postgres tests (if planner picks) | ✗ | v0.42.0 | Use compose Postgres + `TEST_DATABASE_URL` (recommended above) | + +**Missing dependencies with no fallback:** None. All dependencies are `go get` away. +**Missing dependencies with fallback:** testcontainers-go is optional; recommended fallback (compose Postgres) is already running locally. + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go stdlib `testing` (Go 1.26.1) | +| Config file | none (Go convention) | +| Quick run command | `go test ./internal/auth/... ./internal/web/...` | +| Full suite command | `just test` (runs `just generate` then `go test ./...`) | +| Integration tests | Real Postgres via `TEST_DATABASE_URL` env var; skip if unset (mirrors `pool_test.go`) | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| AUTH-01 | argon2id Hash/Verify round-trip | unit | `go test ./internal/auth -run TestPassword_HashVerify` | ❌ Wave 2 | +| AUTH-01 | Verify rejects wrong password (constant-time) | unit | `go test ./internal/auth -run TestPassword_VerifyWrong` | ❌ Wave 2 | +| AUTH-01 | Verify rejects malformed PHC | unit | `go test ./internal/auth -run TestPassword_VerifyMalformed` | ❌ Wave 2 | +| AUTH-01 | Verify rejects incompatible argon2 version | unit | `go test ./internal/auth -run TestPassword_VerifyVersion` | ❌ Wave 2 | +| AUTH-01 | Signup happy path: insert user + create session + 303 to / | integration (real DB) | `go test ./internal/web -run TestSignup_Success` | ❌ Wave 4 | +| AUTH-01 | Signup rejects invalid email | unit | `go test ./internal/web -run TestSignup_InvalidEmail` | ❌ Wave 4 | +| AUTH-01 | Signup rejects short / long password | unit | `go test ./internal/web -run TestSignup_PasswordPolicy` | ❌ Wave 4 | +| AUTH-01 | Signup rejects duplicate email | integration | `go test ./internal/web -run TestSignup_DuplicateEmail` | ❌ Wave 4 | +| AUTH-02 | Login happy path with valid creds → 303 + cookie | integration | `go test ./internal/web -run TestLogin_Success` | ❌ Wave 5 | +| AUTH-02 | Login wrong password → generic error, no cookie | integration | `go test ./internal/web -run TestLogin_WrongPassword` | ❌ Wave 5 | +| AUTH-02 | Login unknown email → same generic error | integration | `go test ./internal/web -run TestLogin_UnknownEmail` | ❌ Wave 5 | +| AUTH-02 | Session row inserted with hashed ID (NOT raw token) | integration | `go test ./internal/auth -run TestSession_StoresHashedID` | ❌ Wave 3 | +| AUTH-02 | Session rotation on login deletes old row | integration | `go test ./internal/auth -run TestSession_RotateDeletesOld` | ❌ Wave 3 | +| AUTH-03 | Cookie has HttpOnly + SameSite=Lax + Path=/ | unit | `go test ./internal/web -run TestLogin_CookieAttrs` | ❌ Wave 5 | +| AUTH-03 | Cookie has Secure when ENV != "dev" | unit | `go test ./internal/web -run TestLogin_CookieSecure` | ❌ Wave 5 | +| AUTH-03 | Subsequent GET / with cookie → 200 (authed) | integration | `go test ./internal/web -run TestSession_Persists` | ❌ Wave 6 | +| AUTH-03 | Expired session (`expires_at < now`) → treated as missing | integration | `go test ./internal/auth -run TestSession_Expired` | ❌ Wave 3 | +| AUTH-03 | Session extends `expires_at` only when < 7 days remain | integration | `go test ./internal/auth -run TestSession_MaybeExtend` | ❌ Wave 3 | +| AUTH-04 | POST /logout → DELETE row + clear cookie (`Max-Age=-1`) | integration | `go test ./internal/web -run TestLogout` | ❌ Wave 6 | +| AUTH-04 | POST /logout without auth → redirect to /login | unit | `go test ./internal/web -run TestLogout_Unauth` | ❌ Wave 6 | +| AUTH-05 | GET / unauth → 303 + Location: /login | unit | `go test ./internal/web -run TestProtected_Redirect` | ❌ Wave 6 | +| AUTH-05 | GET / unauth + HX-Request → HX-Redirect: /login | unit | `go test ./internal/web -run TestProtected_HXRedirect` | ❌ Wave 6 | +| AUTH-05 | GET /login authed → 303 + Location: / | integration | `go test ./internal/web -run TestRedirectIfAuthed` | ❌ Wave 6 | +| AUTH-06 | POST /login without CSRF token → 403 | integration | `go test ./internal/web -run TestCSRF_LoginMissingToken` | ❌ Wave 7 | +| AUTH-06 | POST /login with valid CSRF token → 200/303 | integration | `go test ./internal/web -run TestCSRF_LoginValidToken` | ❌ Wave 7 | +| AUTH-06 | POST /logout without CSRF → 403 | integration | `go test ./internal/web -run TestCSRF_LogoutMissingToken` | ❌ Wave 7 | +| AUTH-06 | All templ forms contain `name="_csrf"` hidden input | smoke | `go test ./templates -run TestForms_HaveCSRF` (greps rendered output) | ❌ Wave 7 | +| AUTH-07 | 5 login attempts in <1min pass; 6th returns 429 | unit (injectable clock) | `go test ./internal/auth -run TestRateLimit_Burst` | ❌ Wave 5 | +| AUTH-07 | Rate-limit key isolates per email+IP | unit | `go test ./internal/auth -run TestRateLimit_PerKey` | ❌ Wave 5 | +| AUTH-07 | Janitor evicts idle entries after TTL | unit (injectable clock) | `go test ./internal/auth -run TestRateLimit_Janitor` | ❌ Wave 5 | +| Manual UAT | Sign up → log out → log in → see home in real browser | manual | per HUMAN-UAT.md (planner authors) | manual | + +### Sampling Rate +- **Per task commit:** `go test ./internal/auth/... ./internal/web/...` (unit + fast integration; skips real-DB tests if `TEST_DATABASE_URL` unset). +- **Per wave merge:** `TEST_DATABASE_URL=... just test` (full suite including real-DB tests). +- **Phase gate:** Full suite + `go vet ./...` + manual browser walkthrough green before `/gsd-verify-work`. + +### Wave 0 Gaps +- [ ] `backend/internal/auth/password_test.go` — covers AUTH-01 password unit cases. +- [ ] `backend/internal/auth/session_test.go` — covers AUTH-02/03 session lifecycle (real DB). +- [ ] `backend/internal/auth/ratelimit_test.go` — covers AUTH-07 (injectable `now()`). +- [ ] `backend/internal/web/handlers_auth_test.go` — covers AUTH-01..06 handler integration. +- [ ] `backend/internal/web/testdb.go` (test helper) — `setupTestDB(t)` creates a schema, runs migrations, returns `*pgxpool.Pool`; drops schema in `t.Cleanup`. +- [ ] No framework install needed (stdlib `testing` only). +- [ ] No CI config in Phase 2 (deferred to Phase 7 deploy). + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | argon2id with OWASP 2024 params; per-password salt; constant-time verify; rate-limited login (AUTH-07); generic error message to block user enumeration on login (D-20) | +| V3 Session Management | yes | Opaque 32-byte token (crypto/rand); SHA-256 hash stored in DB (D-05); HttpOnly + Secure (ENV != dev) + SameSite=Lax (D-12); rotation on login + signup (D-10); server-side invalidation on logout (D-06); sliding 30-day TTL with lazy expiry (D-07, D-09) | +| V4 Access Control | partial | `RequireAuth` middleware on protected routes (D-23); full owners-only access lands in Phase 3 (Tablos) | +| V5 Input Validation | yes | `net/mail.ParseAddress` for email; password length 12–128 (D-25); templ auto-escape on all output; no `templ.Raw` on user input | +| V6 Cryptography | yes | argon2id (`x/crypto/argon2`); `crypto/rand` for tokens; `crypto/subtle.ConstantTimeCompare`; no hand-rolled crypto | +| V7 Error Handling & Logging | yes | Generic login error to user (D-20); slog never logs raw password, hash, full email (log SHA-256 prefix or just event); no cookie/header values in logs (carried from Phase 1) | +| V8 Data Protection | yes | `password_hash` is PHC string only (no plaintext anywhere); SHA-256(token) in DB, not raw; `SESSION_SECRET` from env, never in repo | +| V13 API & Web Service | partial | CSRF on all state-changing requests (D-14, AUTH-06); 303 redirects after POST | +| V14 Configuration | yes | `SESSION_SECRET` from env, 32 bytes hex; `.env.example` documents (with placeholder); `.env` gitignored; `ENV` switches Secure cookie/csrf flag | + +### Known Threat Patterns for {Go web + Postgres + HTMX + Sessions} + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Credential stuffing | Spoofing | `x/time/rate` per-(email+IP) 5/min burst 5 (AUTH-07); generic error message | +| Brute-force password guess | Spoofing | argon2id 64MiB/t=1/p=4 (~250ms/attempt); rate limit | +| User enumeration via login error | Information disclosure | Generic "Invalid email or password" string for both unknown-email and wrong-password (D-20) | +| User enumeration via signup error | Information disclosure | ACCEPTED tradeoff: signup DOES reveal "email already taken" — explicitly noted in CONTEXT.md "Specific Ideas" | +| Session fixation | Spoofing | Rotate session on every login + signup (D-10) | +| Session hijack via DB leak | Information disclosure | DB stores SHA-256(token), not raw token (D-05) | +| Session hijack via XSS | Tampering | `HttpOnly` cookie (D-12); templ auto-escape blocks XSS injection | +| CSRF on logout (GET-based) | Tampering | Logout is POST-only with CSRF token (D-22) | +| CSRF on state-changing requests | Tampering | gorilla/csrf middleware (D-14, AUTH-06) | +| Replay across browser sessions | Tampering | `SameSite=Lax` blocks cross-site POST; CSRF token blocks same-site with stale token | +| Timing attack on password verify | Information disclosure | `subtle.ConstantTimeCompare`; argon2 itself is also constant-time | +| Timing attack on session lookup | Information disclosure | Single SELECT; DB-level latency dominates; not a known practical attack on opaque-token lookup | +| Long password DoS | DoS | Reject `len(pw) > 128` BEFORE argon2 call (D-25) | +| Rate-limiter memory exhaustion | DoS | Janitor evicts idle entries (D-16) | +| Race condition on duplicate signup | Tampering | DB UNIQUE constraint on `email citext` rejects second insert; handler maps `pgx.ErrUniqueViolation` to user error | +| Cookie not sent in HTTPS | Information disclosure | `Secure` flag in prod (D-12); HSTS not in Phase 2 scope (Phase 7) | +| `Strict-Transport-Security` absent | DoS / Tampering | Deferred to Phase 7 deploy headers | + +## Sources + +### Primary (HIGH confidence) +- pkg.go.dev — argon2: https://pkg.go.dev/golang.org/x/crypto/argon2 +- pkg.go.dev — gorilla/csrf: https://pkg.go.dev/github.com/gorilla/csrf +- pkg.go.dev — golang.org/x/time/rate: https://pkg.go.dev/golang.org/x/time/rate +- pkg.go.dev — net/http (Cookie, SetCookie): https://pkg.go.dev/net/http#Cookie +- pkg.go.dev — crypto/subtle: https://pkg.go.dev/crypto/subtle +- pkg.go.dev — crypto/rand: https://pkg.go.dev/crypto/rand +- pkg.go.dev — crypto/sha256: https://pkg.go.dev/crypto/sha256 +- pkg.go.dev — net/mail: https://pkg.go.dev/net/mail +- pkg.go.dev — pgx/v5/pgtype: https://pkg.go.dev/github.com/jackc/pgx/v5/pgtype +- gorilla/csrf README + source: https://github.com/gorilla/csrf +- OWASP Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html +- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html +- Phase 1 RESEARCH (chi middleware order, slog handler switch): `.planning/phases/01-foundation/01-RESEARCH.md` + +### Secondary (MEDIUM confidence) +- Alex Edwards, "How to Hash and Verify Passwords with Argon2 in Go" (canonical PHC encode/decode): https://www.alexedwards.net/blog/how-to-hash-and-verify-passwords-with-argon2-in-go +- Alex Edwards, "How to Rate Limit HTTP Requests in Go": https://www.alexedwards.net/blog/how-to-rate-limit-http-requests +- alexedwards/argon2id wrapper: https://github.com/alexedwards/argon2id +- chia-goths reference example (chi + gorilla/csrf + templ + HTMX): https://chia-goths.fly.dev/csrf-testing +- testcontainers-go Postgres module: https://golang.testcontainers.org/modules/postgres/ +- gorilla/csrf issue #139 (chi mounting clarifications): https://github.com/gorilla/csrf/issues/139 +- bigskysoftware/htmx issue #70 (CSRF token in HTMX requests): https://github.com/bigskysoftware/htmx/issues/70 +- sqlc issue #3441 (pgx/v5 custom-type registration): https://github.com/sqlc-dev/sqlc/issues/3441 + +### Tertiary (LOW confidence) +- "Lucia-style" session-token-hashing-in-DB pattern — convention, not RFC; verified by industry adoption rather than spec. + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — every version verified via `go list -m -versions` on 2026-05-14. +- Architecture: HIGH — fully locked in CONTEXT.md (D-01..D-26); research only added the concrete API patterns. +- Pitfalls: HIGH — drawn from canonical Go docs, official package source, and verified community examples (gorilla/csrf body-consumption, sha256.Sum vs Sum256, MaxAge -1 vs 0, 303 vs 302). +- Code examples: HIGH — Pattern 1 (argon2 PHC) is the canonical alex-edwards form; Pattern 8 (rate limit) is the canonical Go-tutorial form with the `AllowN(t, n)` testability tweak. +- Testing strategy: HIGH for the compose-Postgres path; MEDIUM for testcontainers (podman friction, A7). +- Security: HIGH — ASVS coverage maps cleanly to D-01..D-26; no hand-rolled crypto introduced. + +**Research date:** 2026-05-14 +**Valid until:** 2026-06-14 (30 days). Re-verify gorilla/csrf, golang.org/x/crypto, golang.org/x/time versions if execution slips past this.