# Phase 2: Authentication - Discussion Log

> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.

**Date:** 2026-05-14
**Phase:** 2-Authentication
**Areas discussed:** Users + sessions schema, Hash algo + session lifetime, CSRF + rate-limit strategy, Auth pages UX (HTMX flavor)

---

## Users + sessions schema

### Q1 — Email storage strategy for the users table

| Option | Description | Selected |
|--------|-------------|----------|
| citext + normalize on write | citext column (case-insensitive). Insert/lookup with lowercased value. Single unique index. | ✓ |
| text + lower(email) unique index | Standard text + UNIQUE on lower(email). Requires every lookup to use lower(). | |
| text + app-side normalize to lowercase | Plain text + regular UNIQUE; lowercase in Go before insert/select. | |

**User's choice:** citext + normalize on write
**Notes:** Migration must `CREATE EXTENSION IF NOT EXISTS citext;` before creating the users table.

### Q2 — Sessions table: how is the cookie value stored?

| Option | Description | Selected |
|--------|-------------|----------|
| Opaque token, hashed in DB | 32 bytes crypto/rand; cookie carries raw token; DB stores SHA-256(token). DB leak ≠ session hijack. | ✓ |
| Opaque token, plain in DB | Cookie carries raw token; DB stores it as-is. | |
| HMAC-signed cookie + DB row id | Cookie is signed(session_id); server verifies HMAC then loads row. Two moving parts. | |

**User's choice:** Opaque token, hashed in DB

### Q3 — Capture client metadata on the sessions row?

| Option | Description | Selected |
|--------|-------------|----------|
| user_agent + ip_address + last_seen_at | Enables "active sessions" UI later; one UPDATE per authed request. | |
| Only created_at + expires_at | Minimal columns. Cheapest write path; no audit columns. | ✓ |
| UA + IP at creation only | Audit columns at create, no per-request UPDATE. | |

**User's choice:** Only created_at + expires_at
**Notes:** Deliberately lean — no audit columns. Active-sessions UI is deferred.

### Q4 — Expired/logged-out session cleanup?

| Option | Description | Selected |
|--------|-------------|----------|
| DELETE on logout + lazy expiry check | Hard-delete on logout; middleware filters expires_at < now() in the SELECT; Phase 6 worker adds sweep. | ✓ |
| DELETE on logout, no cleanup job | Expired rows accumulate until later. | |
| Soft-delete (revoked_at) | Keep audit trail; more disk, privacy questions. | |

**User's choice:** DELETE on logout + lazy expiry check (sweep job → Phase 6)

---

## Hash algo + session lifetime

### Q1 — Password hashing algorithm (roadmap user-in-loop)

| Option | Description | Selected |
|--------|-------------|----------|
| argon2id (golang.org/x/crypto/argon2) | OWASP first choice; memory-hard; params t=1, m=64MiB, p=4. | ✓ |
| bcrypt (golang.org/x/crypto/bcrypt) | Battle-tested; single cost knob; not memory-hard. | |

**User's choice:** argon2id

### Q2 — Session lifetime policy

| Option | Description | Selected |
|--------|-------------|----------|
| Sliding 30 days, no remember-me | Extend expires_at when remaining < 7d (write-rate cap). Simplest UX. | ✓ |
| Absolute 7 days, no extension | Hard cutoff; re-login weekly. | |
| Absolute 30d + optional remember-me to 90d | Two-mode with a checkbox. | |

**User's choice:** Sliding 30 days, no remember-me

### Q3 — Rotate session ID on login?

| Option | Description | Selected |
|--------|-------------|----------|
| New session row + new cookie on every login | Session-fixation defense; same createSession() path for login and signup. | ✓ |
| Reuse existing session if already authed | Faster; loses fixation defense. | |

**User's choice:** Rotate on every login

### Q4 — Signup-to-login behavior

| Option | Description | Selected |
|--------|-------------|----------|
| Signup auto-logs-in | POST /signup → insert user → issue session → redirect. One step. No verification email in v1. | ✓ |
| Signup requires explicit login afterward | Two-step flow with success flash. Friction without payoff. | |

**User's choice:** Signup auto-logs-in

---

## CSRF + rate-limit strategy

### Q1 — CSRF library/approach

| Option | Description | Selected |
|--------|-------------|----------|
| gorilla/csrf middleware | Mature, chi-compatible. Per-session token; reads from form field or X-CSRF-Token header. | ✓ |
| justinas/nosurf | Lighter alternative; less widely deployed. | |
| Hand-rolled double-submit cookie | Total control, more code to test, easy to get subtly wrong. | |

**User's choice:** gorilla/csrf

### Q2 — How is the CSRF token surfaced in templ pages?

| Option | Description | Selected |
|--------|-------------|----------|
| Hidden input via templ helper component | `@csrf.Field(ctx)` renders a hidden input; library reads the form value. Zero JS. | ✓ |
| Meta tag + hx-headers config | Render token in <meta>; configure HTMX globally; tiny inline JS. | |

**User's choice:** Hidden input via templ helper component

### Q3 — Rate limiter backend

| Option | Description | Selected |
|--------|-------------|----------|
| In-memory token bucket on email+IP | golang.org/x/time/rate; map + sync.Mutex + janitor; ephemeral across restarts. | ✓ |
| DB-backed counters (login_attempts table) | Survives restart; adds writes to hot path; needs sweep job. | |
| In-memory bucket on IP only | Simpler key; misses targeted credential stuffing from a botnet. | |

**User's choice:** In-memory token bucket on email+IP

### Q4 — Rate-limit response behavior

| Option | Description | Selected |
|--------|-------------|----------|
| 429 + generic "too many attempts" + 60s cooldown | Inline HTMX-swapped error; no captcha. | ✓ |
| Silent slowdown (artificial 2s delay) | Don't tell the attacker; confuses legitimate typo users. | |
| Hard block for fixed window (5 min) | Stricter UX cost. | |

**User's choice:** 429 + generic message + 60s cooldown

---

## Auth pages UX (HTMX flavor)

### Q1 — Page structure for /login and /signup

| Option | Description | Selected |
|--------|-------------|----------|
| Full-page GET, POST returns HTMX fragment on error / HX-Redirect on success | Inline error swap; HX-Redirect: / on success. | ✓ |
| Full-page GET, POST returns full page on error | Plain form submit + re-render. Simpler; loses HTMX practice point. | |
| Single combined /auth page with tabs | One route with tabs; more UI. | |

**User's choice:** Full-page GET + HTMX fragment on error / HX-Redirect on success
**Notes:** Forms must still work without HTMX (graceful 303 + Location for non-HTMX submits) per the broader project principle.

### Q2 — Login error message granularity

| Option | Description | Selected |
|--------|-------------|----------|
| Generic "Invalid email or password" for both wrong-email and wrong-password | Prevents user enumeration. | ✓ |
| Specific ("No account with that email" vs "Wrong password") | Friendlier; enables enumeration. | |

**User's choice:** Generic message
**Notes:** Signup errors can still reveal "email already taken" — standard tradeoff.

### Q3 — Post-login redirect destination

| Option | Description | Selected |
|--------|-------------|----------|
| Always /, ignore return-to | Simplest; nothing meaningful to deep-link yet. | ✓ |
| Honor ?next= with same-origin allow-list | Future-proofs deep-linking; adds allow-list check. | |

**User's choice:** Always /
**Notes:** Revisit in Phase 3+ when deep-link return-to becomes useful.

### Q4 — Where does the logout control live?

| Option | Description | Selected |
|--------|-------------|----------|
| POST /logout form button in base layout when authed | Visible header button; CSRF-protected POST. | ✓ |
| Logout only on a dedicated /account page | Cleaner header but adds a route not needed in Phase 2. | |

**User's choice:** POST /logout form button in base layout

---

## Claude's Discretion

- Exact package layout under `internal/auth/` and whether to absorb the Phase 1 `internal/session/` stub.
- Final cookie name and token byte length within sane bounds (24–48 bytes).
- argon2 sub-parameter tuning if benchmarking shows the OWASP baseline is too slow on the target host.
- Specific HTML/Tailwind look of `/login` and `/signup` (must reuse `internal/web/ui` primitives).
- Whether to introduce a small flash-message helper for the post-logout message.
- File structure of the in-memory rate limiter (must accept an injectable clock for tests).
- CSRF key env var name (`SESSION_SECRET` reuse vs dedicated `CSRF_KEY`).
- Whether `POST /logout` lives on the protected group (default: require auth to log out).
- Choice of real-Postgres test approach: `testcontainers-go` vs a `TEST_DATABASE_URL` against the compose Postgres. Transaction-rollback vs per-package schema also planner's call.

## Deferred Ideas

- Password reset / forgot-password flow — needs email infra; not v1.
- Email verification — same reason; no email infra in v1.
- OAuth / social login — explicitly out of scope (PROJECT.md).
- Magic-link login — that's the client-portal pattern (v2 CLIENT-01).
- MFA / TOTP / passkeys / WebAuthn — beyond v1.
- Account settings page (/account) — future phase.
- Session-list "active devices" UI — would require the UA/IP/last_seen columns we deliberately omitted.
- DB-backed rate limiting (login_attempts table) — only if in-memory proves insufficient (multi-replica deployments — v1 is single-host).
- Captcha on repeated rate-limit hits — out of scope.
- `?next=` deep-link return-to on /login — deferrable to Phase 3+.
- Flash message helper — nice-to-have; planner may inline a minimal version.
- Session sweep / GC job — explicitly punted to Phase 6 (worker phase).
- Production logging / email redaction — Phase 7 operational concern.
