73 KiB
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:
userscolumns: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. Enablecitextextension in the migration (CREATE EXTENSION IF NOT EXISTS citext). - D-02: No
deleted_atcolumn. Hard delete only. - D-03: No
email_verified_atcolumn.
Database Schema — sessions
- D-04:
sessionscolumns: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 onuser_idandexpires_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 = $1and 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_atONLY 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-Agemirrorsexpires_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/csrfmiddleware, mounted AFTERResolveSessionand BEFORE POST/PUT/DELETE handlers.csrf.Secure(ENV != "dev")+csrf.SameSite(csrf.SameSiteLaxMode). Reads_csrfform field orX-CSRF-Tokenheader. - 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(orsync.Map). Janitor goroutine evicts idle > 10 min. - D-17: Client IP via
chimw.RealIPalready in stack; readr.RemoteAddrpost-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 OtherLocation: /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.RequireAuthreads session from ctx (set by always-runningResolveSession), redirects unauth →/login(303orHX-Redirect).RedirectIfAuthedmiddleware wrapsGET /login+GET /signupto bounce authed users →/. - D-24: Middleware order:
RequestID → RealIP → SlogLogger → Recoverer → ResolveSession → csrf.Protect → [route groups].
Password Policy
- D-25:
net/mail.ParseAddressfor 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/vsinternal/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/uiButton/Card). - Whether to add a
flashhelper 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_SECRETorCSRF_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:
- Wave 1 — Schema + sqlc. Migration
0002_auth.sql(citext extension + users + sessions).internal/db/queries/users.sql+sessions.sql.sqlc generate. Critical: sqlc emitspgtype.Textforcitextunless overridden — add anoverridesblock insqlc.yamlsocitextmaps to Gostring. - Wave 2 — Password package (
internal/auth/password.go): argon2id PHC encode + decode + constant-time verify. Self-test ininit(). Reduced-cost params via env override for tests. - Wave 3 — Session package + middleware (
internal/auth/session.go+internal/web/middleware_auth.go): token generate / hash / store / lookup / rotate / delete;ResolveSession,RequireAuth,RedirectIfAuthedmiddleware; cookie helpers. - Wave 4 — Signup flow end-to-end (handler + templ + e2e test): validate → hash → insert user → createSession → set cookie → HX-Redirect /.
- Wave 5 — Login flow end-to-end (handler + templ + rate limit): rate check → lookup user → verify hash → rotate session → set cookie → HX-Redirect /.
- Wave 6 — Logout + protected routes: POST /logout handler + base-layout button + protect home route.
- 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) 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 (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.Versionis0x13(19decimal) — always store in PHC and reject mismatches on verify.subtle.ConstantTimeComparereturns1on equal,0otherwise. Never usebytes.Equalfor 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.
Pattern 3: Cookie issuance (D-12)
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) → ifHX-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.Equalor==for hash comparison. Timing leak. Usesubtle.ConstantTimeCompare. - Hashing the password without a per-password salt. argon2 won't catch this —
IDKeyaccepts whatever salt you pass. Generate one fresh withcrypto/randper call. - Calling
csrf.Token(r)beforecsrf.Protectruns. Returns empty string. Templ helpers must receive the token from a handler that runs INSIDE the middleware chain. - Mounting
csrf.ProtectbeforeResolveSession. CSRF cookie is independent but downstream handlers may want to skip CSRF for some pre-auth endpoints; ordering per D-24 keepsResolveSessioncheap andcsrf.Protectsecond. - Using
Allow()(no args) in the rate limiter. Untestable. UseAllowN(s.now(), 1). - Re-using the same
*rate.Limiteracross all keys. That's a global limit, not per-key. Must map[key]*Limiter. - Calling
pool.PinginNewPool. 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: 0to delete a cookie. That means "no Max-Age attribute" (session cookie). Use-1. - Hardcoding the CSRF key. Must come from env (
SESSION_SECRETorCSRF_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
-- 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 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=Strictfor 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
-
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_URLpath for Phase 2:- Reuse the existing
compose.yamlPostgres (already running forjust 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_URLto.env.example(planner picks default, e.g.postgres://xtablo:xtablo@localhost:5432/xtablo_test?sslmode=disable). - Tests skip with
t.SkipifTEST_DATABASE_URLis unset, mirroring the existinginternal/db/pool_test.gopattern.
- Reuse the existing
- Why: Avoids the podman+testcontainers compatibility risk (A7), avoids per-test container startup latency on macOS (~5–8s), and the developer already has the compose Postgres running during
just dev. Reserve testcontainers-go as a Phase 7+ option (CI ephemerality, fresh-DB-per-test) once value/risk is clearer. - Status: RECOMMENDED; planner may override if preferred.
- What we know: D-26 leaves it to the planner. Phase 1 already runs Postgres via
-
alexedwards/argon2idwrapper vs hand-rolled — recommendation- What we know: D-08 phrasing ("hash code lives in
internal/auth/password.gowith 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.
- What we know: D-08 phrasing ("hash code lives in
-
internal/auth/package layout — recommendation- What we know: D-71 (Claude's discretion) says planner picks; Phase 1 has an empty
internal/session/doc.goplaceholder. - Recommendation: Consolidate everything into
internal/auth/(password, session, ratelimit, cookie, csrf helpers). Deleteinternal/session/(or repurpose itsdoc.goto point atinternal/auth). Single package = single import, easier to reason about. - Status: RECOMMENDED.
- What we know: D-71 (Claude's discretion) says planner picks; Phase 1 has an empty
-
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 ifTEST_DATABASE_URLunset). - 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 (injectablenow()).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 int.Cleanup.- No framework install needed (stdlib
testingonly). - 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 -versionson 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.