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:** `