diff --git a/.planning/phases/02-authentication/02-CONTEXT.md b/.planning/phases/02-authentication/02-CONTEXT.md
new file mode 100644
index 0000000..d0e1329
--- /dev/null
+++ b/.planning/phases/02-authentication/02-CONTEXT.md
@@ -0,0 +1,169 @@
+# Phase 2: Authentication - Context
+
+**Gathered:** 2026-05-14
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+A new user can sign up with email + password, log in, and stay logged in across requests via a server-managed session stored in an HTTP-only signed cookie. Protected routes redirect unauthenticated users to `/login`; authed users on `/login` go to `/`. Logout invalidates the session server-side. CSRF protection covers all state-changing requests. Login attempts are rate-limited per email+IP.
+
+Delivers AUTH-01..07. **Not** in scope: password reset / forgot-password flow, email verification, OAuth / social login, magic links, account settings page, multi-factor auth, third-party auth providers (explicitly out of scope per PROJECT.md), session listing UI ("active sessions"), Tablos CRUD (Phase 3).
+
+
+
+
+## Implementation Decisions
+
+### Database Schema — users
+- **D-01:** `users` table 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()`. Email uses `citext` for native case-insensitive uniqueness; the app additionally normalizes (lowercases + trims) on insert to keep stored values canonical. Enable the `citext` extension in the migration (`CREATE EXTENSION IF NOT EXISTS citext`).
+- **D-02:** No `deleted_at` — hard delete only (not exercised in Phase 2; users can't currently delete themselves, but no soft-delete column reserved).
+- **D-03:** No `email_verified_at` column — email verification is deferred (no flow in v1).
+
+### Database Schema — sessions
+- **D-04:** `sessions` table columns: `id text PK` (the SHA-256 hash of the opaque token, hex-encoded), `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `created_at timestamptz NOT NULL default now()`, `expires_at timestamptz NOT NULL`. Index on `user_id` (for "delete all sessions for user" sweeps) and on `expires_at` (for the future cleanup job). No `user_agent`, `ip_address`, or `last_seen_at` columns in v1.
+- **D-05:** Cookie value is the raw 32-byte (crypto/rand) token, base64url-encoded. DB stores SHA-256(token) hex-encoded. A DB read leak does NOT expose live sessions. Server hashes the incoming cookie on every authed request to look up the row.
+- **D-06:** Logout hard-deletes the session row (`DELETE FROM sessions WHERE id = $1`) and expires the cookie (`Max-Age=0`).
+- **D-07:** Lazy expiry — auth middleware treats rows where `expires_at < now()` as missing (the lookup query includes `AND expires_at > now()`). Phase 6 worker adds a daily sweep job to GC stale rows; Phase 2 itself ships no background cleanup.
+
+### Password Hashing
+- **D-08:** **argon2id** via `golang.org/x/crypto/argon2`. OWASP 2024 baseline parameters: `t=1` (time), `m=64*1024` (64 MiB memory), `p=4` (parallelism), 16-byte random salt per password, 32-byte output. Stored as the standard PHC-formatted string `$argon2id$v=19$m=65536,t=1,p=4$$`. The hash code lives in `internal/auth/password.go` (or similar) with a tiny self-test that re-verifies a known password to catch param-drift regressions.
+
+### Session Lifetime
+- **D-09:** Sliding 30 days. `expires_at = now() + 30 days` set on session create. On each authenticated request, the middleware extends `expires_at` ONLY when the remaining lifetime drops below 7 days (i.e. update runs once per session per ~23 days). This keeps write volume off the hot path while preserving the sliding behavior. No "remember me" checkbox.
+- **D-10:** **Rotate** the session ID on every successful login and on signup auto-login. New random token → new DB row → new cookie. Mitigates session-fixation. The path `POST /login` and `POST /signup` go through the same `createSession(userID)` helper.
+- **D-11:** Signup auto-logs-in: `POST /signup` validates → inserts user → creates session → `HX-Redirect: /` (or `Location: /` for non-HTMX submits). No verification email in v1.
+
+### Cookie Properties
+- **D-12:** Cookie name: `xtablo_session` (or similar — final name in plan). Attributes: `HttpOnly`, `Secure` (gated by `ENV != "dev"` — dev runs on plain http://localhost), `SameSite=Lax` (default; allows top-level GET navigation from external sites, blocks cross-site POST), `Path=/`. `Max-Age` matches `expires_at` and is reissued whenever the session is extended.
+- **D-13:** Cookie value is the raw base64url token only — no extra signing layer. Tampering is detected when SHA-256 lookup fails to find a row. (Signing would add a layer without changing the threat model since the DB lookup is the source of truth.)
+
+### CSRF
+- **D-14:** **`github.com/gorilla/csrf`** middleware mounted on the chi stack AFTER the session-resolve middleware but BEFORE any handler that processes POST/PUT/PATCH/DELETE. Configure with `csrf.Secure(ENV != "dev")` and `csrf.SameSite(csrf.SameSiteLaxMode)`. Token is per-session, stored in its own HTTP-only cookie by gorilla/csrf. The library reads the token from the `_csrf` form field OR the `X-CSRF-Token` header (the latter reserved for future HTMX hx-headers usage).
+- **D-15:** Token surfacing: a small `internal/web/ui` (or `internal/auth/csrf`) templ helper component renders the hidden input — e.g. `@csrf.Field(ctx)` → ``. Every templ form includes this component as the first child. No JS, no meta tags.
+
+### Rate Limiting
+- **D-16:** Login rate limit: **in-memory token bucket** keyed on `lower(email) + ":" + clientIP`, using `golang.org/x/time/rate`. Config: 5 requests per minute per key (rate = 5/min, burst = 5). Lookup map `map[string]*rate.Limiter` guarded by `sync.Mutex` (or `sync.Map`); a janitor goroutine evicts entries idle > 10 minutes to bound memory. Ephemeral across restarts — acceptable for the >5/min threshold.
+- **D-17:** Client IP source: `chimw.RealIP` is already in the Phase 1 middleware stack — read from `r.RemoteAddr` after that middleware has rewritten it. The IP is part of the rate-limit key only — NOT persisted anywhere.
+- **D-18:** Rate-limit response: `429 Too Many Requests` with an HTMX-swapped inline error fragment on the login form: "Too many attempts. Try again in a minute." Cooldown is implicit in the token-bucket refill rate (~12s per recovered slot). No captcha. The rate limit applies to `POST /login` only — `POST /signup` is not rate-limited in v1 (revisit if abuse appears).
+
+### Auth Pages UX
+- **D-19:** Routes: `GET /login`, `POST /login`, `GET /signup`, `POST /signup`, `POST /logout`. `GET /login` and `GET /signup` render full templ pages using the base layout. Unauthenticated POSTs render an HTMX fragment containing only the form with errors injected; authenticated success returns `HX-Redirect: /` (and `Location: /` + 303 for non-HTMX form submits, so the page works without JS per AUTH-05 / FOUND-philosophy).
+- **D-20:** Login error message is intentionally generic: **"Invalid email or password"** — same string for "no such email" and "wrong password" to avoid user enumeration. Validation errors (empty field, malformed email) ARE specific.
+- **D-21:** Post-login redirect: **always `/`**. No `?next=` honoring in Phase 2; deep-link return-to plumbing can be added later when there are real routes to deep-link to. Phase 3 may revisit if dashboard deep-links become useful.
+- **D-22:** Logout control: POST form with a button rendered in the base layout's header, visible only when the request context carries an authenticated user. CSRF-protected POST (not a GET — prevents prefetchers / image-src attacks from logging users out).
+
+### Protected Routes (chi wiring)
+- **D-23:** Chi route groups: a `Group(func(r chi.Router) { r.Use(RequireAuth) ... })` wraps all routes that need a logged-in user. `RequireAuth` middleware checks the session context (set earlier by a `ResolveSession` middleware that runs always) and either lets the request through or returns `303 See Other` with `Location: /login` (HTMX requests get `HX-Redirect: /login`). A separate `RedirectIfAuthed` middleware wraps `GET /login` and `GET /signup` to bounce already-authed users to `/`.
+- **D-24:** Middleware order in `NewRouter` for Phase 2: `RequestID → RealIP → SlogLogger → Recoverer → ResolveSession → csrf.Protect(...) → [route groups apply RequireAuth / RedirectIfAuthed as needed]`. `ResolveSession` reads the cookie, looks up the session+user, stuffs both into request context; never blocks — it's `RequireAuth` that enforces.
+
+### Password Policy (server-side validation)
+- **D-25:** Minimum constraints (server-side): email looks like an email (Go `net/mail.ParseAddress`), password ≥ 12 characters, password ≤ 128 characters (DoS guard against very long argon2 inputs). No complexity rules (NIST 2023 guidance prefers length). Validation errors render as field-specific inline messages on the form fragment.
+
+### Testing Strategy
+- **D-26:** First phase with real DB tests. Use a real Postgres via `testcontainers-go` OR the `compose.yaml` Postgres reachable through `TEST_DATABASE_URL` — planner chooses. Each test runs in a transaction that rolls back, OR a fresh schema per test package; planner picks. No SQL mocking. argon2 password tests use a reduced-cost test parameter set (smaller `m`) to keep unit-test wall time sane while still exercising the same code path.
+
+### Claude's Discretion
+- Exact package layout under `internal/auth/` vs splitting between `internal/auth/` + `internal/session/` (the `internal/session/` stub from Phase 1 may be absorbed or kept).
+- Final cookie name, session token byte length within sane bounds (24–48 bytes), specific 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` — minimal, consistent with the Phase 1 design system (`internal/web/ui` Button/Card/Badge). One column, centered, clear labels.
+- Whether to introduce a `flash` helper (cookie-backed one-shot messages) for post-logout "you have been logged out" — small QoL, planner decides.
+- File/structure for the in-memory rate limiter (standalone `internal/auth/ratelimit.go` vs embedded in the login handler). It must be unit-testable with an injectable clock.
+- Exact gorilla/csrf authentication key wiring — generate at startup from env (e.g. `SESSION_SECRET` or a dedicated `CSRF_KEY`); planner makes the call but the key MUST come from env (no compile-time constants).
+- Whether `POST /logout` lives on the protected group (require auth to log out) or on a public route that no-ops on missing session (lenient). Default: require auth.
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Project & Scope
+- `.planning/PROJECT.md` — Core value, constraints, out-of-scope list. Note: "Built-in email/password auth with server-managed sessions (no third-party auth)" is in scope; Clerk/Auth0/Lucia explicitly out.
+- `.planning/REQUIREMENTS.md` §Authentication — AUTH-01..07 verbatim.
+- `.planning/ROADMAP.md` §"Phase 2: Authentication" — Success criteria + user-in-loop callouts on schema and hash algorithm.
+
+### Prior Phase Context
+- `.planning/phases/01-foundation/01-CONTEXT.md` — Phase 1 decisions that constrain Phase 2: chi router + middleware stack (D-07/D-08), goose migrations, sqlc + pgx/v5, templ templates, internal/session/ placeholder package, env-driven config, slog structured logging with request ID.
+- `.planning/phases/01-foundation/01-RESEARCH.md` — chi middleware-order findings (RequestID must precede SlogLogger to thread request_id into logs); session resolve middleware will follow the same pattern.
+
+### Codebase Maps (legacy JS app — behavioral reference only)
+- `.planning/codebase/INTEGRATIONS.md` — Notes Supabase Auth is the existing JS auth; this phase replaces it entirely.
+- `.planning/codebase/CONCERNS.md` — Pain points motivating the rewrite (incl. auth/Supabase coupling).
+- Legacy `apps/api/src/routers/authRouter.ts` and `apps/api/src/middlewares/middleware.ts` — Reference ONLY for "what the JS version does"; the new flow does not mirror its JWT/Supabase model.
+
+### Existing Go scaffold
+- `backend/internal/session/doc.go` — Empty placeholder stub from Phase 1; Phase 2 fills (or merges into a new `internal/auth/` package).
+- `backend/internal/web/router.go` / `middleware.go` — Where new auth middleware (`ResolveSession`, `RequireAuth`, `RedirectIfAuthed`) wires in.
+- `backend/migrations/0001_init.sql` — No-op bootstrap from Phase 1. Phase 2 migration is `0002_.sql` (planner picks slug) and includes `CREATE EXTENSION IF NOT EXISTS citext;` plus `users` + `sessions` tables.
+- `backend/sqlc.yaml` — sqlc config; queries land in `internal/db/queries/`, generated into `internal/db/sqlc/`.
+
+### External library docs (planner will pull versions during research)
+- argon2 (Go std-ext): https://pkg.go.dev/golang.org/x/crypto/argon2 — `argon2.IDKey` signature; PHC string format conventions (OWASP cheatsheet)
+- gorilla/csrf: https://github.com/gorilla/csrf — middleware mounting, token field/header names, cookie attributes
+- golang.org/x/time/rate: https://pkg.go.dev/golang.org/x/time/rate — token-bucket Limiter API
+- pgx/v5 citext: pgx + citext type handling notes
+- OWASP Password Storage Cheat Sheet (argon2id params 2024): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
+- OWASP Session Management Cheat Sheet (rotation, lifetime, fixation): https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- `backend/internal/web/middleware.go` (`RequestIDMiddleware`, `SlogLoggerMiddleware`): pattern for context-injecting middleware — `ResolveSession` follows the same shape (`context.WithValue` + typed accessor function).
+- `backend/internal/web/ui/` (Button, Card, Badge templ components + `tokens.go` + `variants.go`): the design system established in Phase 1. Auth forms compose these — no new primitives needed for v1 login/signup.
+- `backend/templates/layout.templ`: the base layout. Phase 2 extends it with the conditional logout button in the header and a place for global flash/error rendering.
+- `backend/internal/db/pool.go` + `pool_test.go`: pgxpool wiring pattern + the existing test approach for DB-touching code.
+
+### Established Patterns
+- Middleware order is locked by Phase 1 RESEARCH and CONTEXT D-08: `RequestID → RealIP → SlogLogger → Recoverer`. Phase 2 appends `ResolveSession → csrf.Protect → (route-group RequireAuth)` after Recoverer.
+- Templ "handler renders templ.Component to w" pattern (see `IndexHandler`, `DemoTimeHandler`). Auth handlers follow the same shape, with HTMX-vs-full detection via `HX-Request` header for response selection.
+- `Pinger` interface pattern (handlers depend on a small interface, not the concrete pool) — the same pattern applies to a `SessionStore` interface for auth, enabling fakes in unit tests.
+- sqlc config emits to `internal/db/sqlc/`; queries live in `internal/db/queries/.sql`. Phase 2 adds `internal/db/queries/auth.sql` (or `users.sql` + `sessions.sql`).
+
+### Integration Points
+- `cmd/web/main.go`: wires the pool into `NewRouter`. Phase 2 extends the constructor to accept a `*sql.DB`-or-pgx-pool-backed `auth.Store` (or constructs it inside `NewRouter`). The CSRF secret comes from env (`SESSION_SECRET` or `CSRF_KEY`) read in `main.go` and passed in.
+- Existing demo route `/demo/time` does NOT need auth; can stay public. Phase 2's chi groups distinguish public vs protected. The home route `/` becomes protected (per AUTH-05: unauthed → `/login`).
+
+
+
+
+## Specific Ideas
+
+- Argon2id specifically with OWASP 2024 baseline params — user confirmed over bcrypt.
+- Sessions store the SHA-256 hash of the opaque token, not the raw token (defense in depth against DB-read leaks). This was an explicit pick over "plain token in DB".
+- Sessions are intentionally lean — only `id, user_id, created_at, expires_at`. User explicitly declined storing `user_agent` / `ip_address` / `last_seen_at` columns.
+- Rotate session on every login is a hard requirement, even if a valid cookie is already present.
+- Signup auto-logs-in (no second step). No email verification anywhere in v1.
+- Login error message is intentionally vague to avoid enumeration; signup error messages can be specific (email already taken IS revealed — standard tradeoff).
+- Logout MUST be a POST form (CSRF-protected), never a GET link.
+- Rate limit applies to login only in v1; signup is not rate-limited.
+
+
+
+
+## Deferred Ideas
+
+- **Password reset / forgot-password flow** — needs email delivery; new capability beyond AUTH-01..07. Future phase.
+- **Email verification** — same reason; no email infrastructure in v1.
+- **OAuth / social login** — explicitly out of scope (PROJECT.md "no third-party auth").
+- **Magic-link login** — that's the client portal pattern (v2 milestone CLIENT-01).
+- **MFA / TOTP / passkeys / WebAuthn** — beyond v1 scope.
+- **Account settings page** (/account) — change password, view active sessions, etc. Belongs in a later phase once Tablos lands.
+- **Session-list "active devices" UI** — would require the UA/IP/last_seen columns we deliberately omitted. Revisit if/when needed.
+- **DB-backed rate limiting** with a `login_attempts` table — only if the in-memory approach proves insufficient (e.g. behind multiple replicas — but v1 is single-binary single-host).
+- **Captcha on repeated rate-limit hits** — out of scope; revisit if abuse seen in production.
+- **`?next=` deep-link return-to on /login** — deferrable until Phase 3+ when protected deep-links exist.
+- **Flash message helper** (one-shot post-redirect messages) — nice-to-have; planner may inline a minimal version if it cleans up logout UX.
+- **Session sweep / GC job** — explicitly punted to Phase 6 (worker phase), per D-07. Phase 2 ships the lazy expiry check only.
+- **Production logging / redaction of email in error logs** — Phase 7 operational concern.
+
+
+
+---
+
+*Phase: 2-Authentication*
+*Context gathered: 2026-05-14*
diff --git a/.planning/phases/02-authentication/02-DISCUSSION-LOG.md b/.planning/phases/02-authentication/02-DISCUSSION-LOG.md
new file mode 100644
index 0000000..aa6c88a
--- /dev/null
+++ b/.planning/phases/02-authentication/02-DISCUSSION-LOG.md
@@ -0,0 +1,212 @@
+# 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 ; 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.