xtablo-source/.planning/phases/02-authentication/02-RESEARCH.md
2026-05-14 21:20:13 +02:00

73 KiB
Raw Blame History

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 12128. 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 (2448 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:

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) 12128)            │
│   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)            │
└──────────────────────────────────────────────────────────────────┘
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 (canonical pattern); pkg.go.dev/golang.org/x/crypto/argon2.

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

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.

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.

Pattern 4: ResolveSession middleware (always runs; never blocks)

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

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

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, pkg.go.dev/github.com/gorilla/csrf.

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):

// 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; Alex Edwards' "How to Rate Limit HTTP Requests in 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:

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

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)

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

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

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

-- 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)

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

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 12128 characters"); return }

        key := email + ":" + clientIP(r)
        if !rl.Allow(key) {
            w.WriteHeader(http.StatusTooManyRequests)
            renderLoginError(w, r, "Too many attempts. Try again in a minute.")
            return
        }

        user, err := store.GetUserByEmail(r.Context(), email)
        if err != nil { renderLoginError(w, r, "Invalid email or password"); return }

        ok, err := auth.Verify(user.PasswordHash, password)
        if err != nil || !ok { renderLoginError(w, r, "Invalid email or password"); return }

        // Rotate any existing session, create new one
        oldID := ""
        if sess, _, ok := authed(r.Context()); ok { oldID = sess.ID }
        cookieValue, expiresAt, err := store.Rotate(r.Context(), oldID, user.ID)
        if err != nil { http.Error(w, "internal error", 500); return }

        setSessionCookie(w, cookieName, cookieValue, expiresAt, secure)

        if r.Header.Get("HX-Request") == "true" {
            w.Header().Set("HX-Redirect", "/")
            w.WriteHeader(http.StatusOK)
            return
        }
        http.Redirect(w, r, "/", http.StatusSeeOther)
    }
}

State of the Art

Old Approach Current Approach When Changed Impact
bcrypt for new services argon2id (RFC 9106) OWASP 2024 cheatsheet update argon2id is the recommended default for new applications
JWT in cookie Opaque token + DB session row Reaction to JWT-revocation pain (~2020+) Server-controlled invalidation, smaller cookie
Raw token in DB SHA-256(token) in DB Lucia/Astro / "production-grade" guides (~2023+) DB read leak ≠ live sessions
SameSite=None cookies SameSite=Lax Chrome default change 2020 Block CSRF without breaking top-level GET nav
302 redirect after POST 303 See Other Industry consensus Force GET follow-up unambiguously

Deprecated/outdated:

  • bcrypt for greenfield (still safe but suboptimal vs argon2id).
  • MD5/SHA-1 for any password handling (broken).
  • Custom CSRF implementations (use gorilla/csrf).
  • SameSite=Strict for primary session cookies (breaks top-level GET nav from email links).

Assumptions Log

# Claim Section Risk if Wrong
A1 golang.org/x/crypto latest is v0.51.0 (provides argon2 sub-pkg) Standard Stack LOW — verified via go list -m -versions. Re-verify if execution slips past 2026-06-14.
A2 gorilla/csrf v1.7.3 supports the chi Use(...) mounting pattern and csrf.Secure/csrf.SameSite option signatures used in Pattern 7 Standard Stack, Pattern 7 LOW — verified via pkg.go.dev and the chia-goths example.
A3 golang.org/x/time/rate v0.15.0 AllowN(t, n) API is unchanged from earlier versions Standard Stack, Pattern 8 LOW — API is stable since ~2018; only additions, no breaks.
A4 Postgres 16 (image used in Phase 1 compose.yaml) supports citext, pgcrypto, and gen_random_uuid() Pattern 11, Pitfall 12 LOW — Postgres 13+ ships gen_random_uuid() in core; citext is universally available since 9.1.
A5 pgx/v5 text codec handles citext columns transparently when sqlc emits Go string (per override) Pattern 10, Pitfall 3 MEDIUM — verified in pgx docs that text codec works for citext on the wire; planner should validate with a Wave 1 integration test before relying on this in handlers.
A6 csrf.Path("/"), csrf.FieldName("_csrf") option signatures are stable in v1.7.x Pattern 7 LOW — confirmed via pkg.go.dev.
A7 testcontainers-go v0.42.0 + the postgres module run cleanly under podman (not just Docker Desktop) on macOS/Linux. The developer's machine uses podman per Phase 1 D-11. Testing Strategy MEDIUM — testcontainers-go supports podman via DOCKER_HOST=unix:///path/to/podman.sock but it's documented as occasionally flaky. This is a real risk worth surfacing for the user — if podman+testcontainers doesn't work cleanly, planner should fall back to the compose-Postgres TEST_DATABASE_URL path.

Interpretation: A7 is the only assumption with non-trivial risk. Resolution is to recommend the TEST_DATABASE_URL path (reuse Phase 1's compose Postgres) as the default in Testing Strategy below, with testcontainers documented as the future fresh-DB-per-test option.

Open Questions

  1. testcontainers-go vs compose Postgres for tests — recommendation

    • What we know: D-26 leaves it to the planner. Phase 1 already runs Postgres via compose.yaml. The developer uses podman. testcontainers-go has known podman friction.
    • What's unclear: Whether testcontainers-go + the postgres module work cleanly with podman on the developer's machine.
    • Recommendation: Use the compose Postgres via TEST_DATABASE_URL path for Phase 2:
      • Reuse the existing compose.yaml Postgres (already running for just dev).
      • Each test creates a unique schema (CREATE SCHEMA test_<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 (~58s), and the developer already has the compose Postgres running during just dev. Reserve testcontainers-go as a Phase 7+ option (CI ephemerality, fresh-DB-per-test) once value/risk is clearer.
    • Status: RECOMMENDED; planner may override if preferred.
  2. alexedwards/argon2id wrapper vs hand-rolled — recommendation

    • What we know: D-08 phrasing ("hash code lives in internal/auth/password.go with a tiny self-test") leans hand-rolled. Wrapper saves ~80 LOC and provides identical PHC format.
    • Recommendation: Hand-roll (Pattern 1 verbatim). The wrapper adds a dep we don't need; the code is short and easily testable; we want the self-test as init()-time invariant per CONTEXT.md.
    • Status: RECOMMENDED hand-rolled. Planner may pick the wrapper if they prefer.
  3. internal/auth/ package layout — recommendation

    • What we know: D-71 (Claude's discretion) says planner picks; Phase 1 has an empty internal/session/doc.go placeholder.
    • Recommendation: Consolidate everything into internal/auth/ (password, session, ratelimit, cookie, csrf helpers). Delete internal/session/ (or repurpose its doc.go to point at internal/auth). Single package = single import, easier to reason about.
    • Status: RECOMMENDED.
  4. Logout: protected group or lenient public?

    • What we know: D-73 (Claude's discretion). Default: protected.
    • Recommendation: Protected (require auth to log out). Cleaner — unauth logouts are a no-op anyway; protected version gets free CSRF + auth context. The base-layout button only renders when authed, so the route is never reachable from an unauth page.
    • Status: RECOMMENDED.

Environment Availability

Dependency Required By Available Version Fallback
Phase 1 stack (Go, chi, templ, pgx, sqlc, goose, air, tailwind, htmx, podman, just) Foundation already in place Per Phase 1 RESEARCH
golang.org/x/crypto/argon2 Password hashing ✗ (not in go.mod yet) v0.51.0 go get golang.org/x/crypto@v0.51.0 in Wave 2
github.com/gorilla/csrf CSRF middleware v1.7.3 go get github.com/gorilla/csrf@v1.7.3 in Wave 7
golang.org/x/time/rate Rate limiter v0.15.0 go get golang.org/x/time@v0.15.0 in Wave 5
Postgres citext extension users.email column ✓ (Postgres 16-alpine ships it) bundled CREATE EXTENSION IF NOT EXISTS citext in migration
Postgres pgcrypto extension gen_random_uuid() ✓ (PG 13+ has core fallback) bundled CREATE EXTENSION IF NOT EXISTS pgcrypto defensive
Random source for SESSION_SECRET CSRF auth key ✓ (openssl rand -hex 32) Document in README under "first-time setup"
testcontainers-go (optional) Hermetic Postgres tests (if planner picks) v0.42.0 Use compose Postgres + TEST_DATABASE_URL (recommended above)

Missing dependencies with no fallback: None. All dependencies are go get away. Missing dependencies with fallback: testcontainers-go is optional; recommended fallback (compose Postgres) is already running locally.

Validation Architecture

Test Framework

Property Value
Framework Go stdlib testing (Go 1.26.1)
Config file none (Go convention)
Quick run command go test ./internal/auth/... ./internal/web/...
Full suite command just test (runs just generate then go test ./...)
Integration tests Real Postgres via TEST_DATABASE_URL env var; skip if unset (mirrors pool_test.go)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
AUTH-01 argon2id Hash/Verify round-trip unit go test ./internal/auth -run TestPassword_HashVerify Wave 2
AUTH-01 Verify rejects wrong password (constant-time) unit go test ./internal/auth -run TestPassword_VerifyWrong Wave 2
AUTH-01 Verify rejects malformed PHC unit go test ./internal/auth -run TestPassword_VerifyMalformed Wave 2
AUTH-01 Verify rejects incompatible argon2 version unit go test ./internal/auth -run TestPassword_VerifyVersion Wave 2
AUTH-01 Signup happy path: insert user + create session + 303 to / integration (real DB) go test ./internal/web -run TestSignup_Success Wave 4
AUTH-01 Signup rejects invalid email unit go test ./internal/web -run TestSignup_InvalidEmail Wave 4
AUTH-01 Signup rejects short / long password unit go test ./internal/web -run TestSignup_PasswordPolicy Wave 4
AUTH-01 Signup rejects duplicate email integration go test ./internal/web -run TestSignup_DuplicateEmail Wave 4
AUTH-02 Login happy path with valid creds → 303 + cookie integration go test ./internal/web -run TestLogin_Success Wave 5
AUTH-02 Login wrong password → generic error, no cookie integration go test ./internal/web -run TestLogin_WrongPassword Wave 5
AUTH-02 Login unknown email → same generic error integration go test ./internal/web -run TestLogin_UnknownEmail Wave 5
AUTH-02 Session row inserted with hashed ID (NOT raw token) integration go test ./internal/auth -run TestSession_StoresHashedID Wave 3
AUTH-02 Session rotation on login deletes old row integration go test ./internal/auth -run TestSession_RotateDeletesOld Wave 3
AUTH-03 Cookie has HttpOnly + SameSite=Lax + Path=/ unit go test ./internal/web -run TestLogin_CookieAttrs Wave 5
AUTH-03 Cookie has Secure when ENV != "dev" unit go test ./internal/web -run TestLogin_CookieSecure Wave 5
AUTH-03 Subsequent GET / with cookie → 200 (authed) integration go test ./internal/web -run TestSession_Persists Wave 6
AUTH-03 Expired session (expires_at < now) → treated as missing integration go test ./internal/auth -run TestSession_Expired Wave 3
AUTH-03 Session extends expires_at only when < 7 days remain integration go test ./internal/auth -run TestSession_MaybeExtend Wave 3
AUTH-04 POST /logout → DELETE row + clear cookie (Max-Age=-1) integration go test ./internal/web -run TestLogout Wave 6
AUTH-04 POST /logout without auth → redirect to /login unit go test ./internal/web -run TestLogout_Unauth Wave 6
AUTH-05 GET / unauth → 303 + Location: /login unit go test ./internal/web -run TestProtected_Redirect Wave 6
AUTH-05 GET / unauth + HX-Request → HX-Redirect: /login unit go test ./internal/web -run TestProtected_HXRedirect Wave 6
AUTH-05 GET /login authed → 303 + Location: / integration go test ./internal/web -run TestRedirectIfAuthed Wave 6
AUTH-06 POST /login without CSRF token → 403 integration go test ./internal/web -run TestCSRF_LoginMissingToken Wave 7
AUTH-06 POST /login with valid CSRF token → 200/303 integration go test ./internal/web -run TestCSRF_LoginValidToken Wave 7
AUTH-06 POST /logout without CSRF → 403 integration go test ./internal/web -run TestCSRF_LogoutMissingToken Wave 7
AUTH-06 All templ forms contain name="_csrf" hidden input smoke go test ./templates -run TestForms_HaveCSRF (greps rendered output) Wave 7
AUTH-07 5 login attempts in <1min pass; 6th returns 429 unit (injectable clock) go test ./internal/auth -run TestRateLimit_Burst Wave 5
AUTH-07 Rate-limit key isolates per email+IP unit go test ./internal/auth -run TestRateLimit_PerKey Wave 5
AUTH-07 Janitor evicts idle entries after TTL unit (injectable clock) go test ./internal/auth -run TestRateLimit_Janitor Wave 5
Manual UAT Sign up → log out → log in → see home in real browser manual per HUMAN-UAT.md (planner authors) manual

Sampling Rate

  • Per task commit: go test ./internal/auth/... ./internal/web/... (unit + fast integration; skips real-DB tests if TEST_DATABASE_URL unset).
  • Per wave merge: TEST_DATABASE_URL=... just test (full suite including real-DB tests).
  • Phase gate: Full suite + go vet ./... + manual browser walkthrough green before /gsd-verify-work.

Wave 0 Gaps

  • backend/internal/auth/password_test.go — covers AUTH-01 password unit cases.
  • backend/internal/auth/session_test.go — covers AUTH-02/03 session lifecycle (real DB).
  • backend/internal/auth/ratelimit_test.go — covers AUTH-07 (injectable now()).
  • backend/internal/web/handlers_auth_test.go — covers AUTH-01..06 handler integration.
  • backend/internal/web/testdb.go (test helper) — setupTestDB(t) creates a schema, runs migrations, returns *pgxpool.Pool; drops schema in t.Cleanup.
  • No framework install needed (stdlib testing only).
  • No CI config in Phase 2 (deferred to Phase 7 deploy).

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes argon2id with OWASP 2024 params; per-password salt; constant-time verify; rate-limited login (AUTH-07); generic error message to block user enumeration on login (D-20)
V3 Session Management yes Opaque 32-byte token (crypto/rand); SHA-256 hash stored in DB (D-05); HttpOnly + Secure (ENV != dev) + SameSite=Lax (D-12); rotation on login + signup (D-10); server-side invalidation on logout (D-06); sliding 30-day TTL with lazy expiry (D-07, D-09)
V4 Access Control partial RequireAuth middleware on protected routes (D-23); full owners-only access lands in Phase 3 (Tablos)
V5 Input Validation yes net/mail.ParseAddress for email; password length 12128 (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)

Secondary (MEDIUM confidence)

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.