# 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>
## 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$<salt>$<hash>`. 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 `<input type="hidden" name="_csrf" value="...">`. 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).
</user_constraints>

<phase_requirements>
## 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 |
</phase_requirements>

## 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 <module>` 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) {
    <input type="hidden" name="_csrf" value={ token }/>
}

// 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:** `<form method="POST">` 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 `<form method="post">` 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) {
    ...
    <header class="bg-slate-50 border-b border-slate-200">
        <div class="mx-auto max-w-5xl px-4 sm:px-6 py-4 flex justify-between items-center">
            <a href="/" class="font-semibold">Xtablo</a>
            if user != nil {
                <form method="POST" action="/logout" class="inline">
                    @ui.CSRFField(csrfToken)
                    <button type="submit" class="text-sm text-slate-700 hover:underline">Log out</button>
                </form>
            }
        </div>
    </header>
    ...
}
```

### 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 (RESOLVED)

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_<uuid>; SET search_path TO test_<uuid>;`) 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:** RESOLVED; 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:** RESOLVED 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:** RESOLVED.

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:** RESOLVED.

## 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.
