18 KiB
Phase 2: Authentication - Context
Gathered: 2026-05-14 Status: Ready for planning
## Phase BoundaryA 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 DecisionsDatabase Schema — users
- D-01:
userstable 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 usescitextfor native case-insensitive uniqueness; the app additionally normalizes (lowercases + trims) on insert to keep stored values canonical. Enable thecitextextension 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_atcolumn — email verification is deferred (no flow in v1).
Database Schema — sessions
- D-04:
sessionstable 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 onuser_id(for "delete all sessions for user" sweeps) and onexpires_at(for the future cleanup job). Nouser_agent,ip_address, orlast_seen_atcolumns 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 includesAND 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$<salt>$<hash>. The hash code lives ininternal/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 daysset on session create. On each authenticated request, the middleware extendsexpires_atONLY 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 /loginandPOST /signupgo through the samecreateSession(userID)helper. - D-11: Signup auto-logs-in:
POST /signupvalidates → inserts user → creates session →HX-Redirect: /(orLocation: /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 byENV != "dev"— dev runs on plain http://localhost),SameSite=Lax(default; allows top-level GET navigation from external sites, blocks cross-site POST),Path=/.Max-Agematchesexpires_atand 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/csrfmiddleware mounted on the chi stack AFTER the session-resolve middleware but BEFORE any handler that processes POST/PUT/PATCH/DELETE. Configure withcsrf.Secure(ENV != "dev")andcsrf.SameSite(csrf.SameSiteLaxMode). Token is per-session, stored in its own HTTP-only cookie by gorilla/csrf. The library reads the token from the_csrfform field OR theX-CSRF-Tokenheader (the latter reserved for future HTMX hx-headers usage). - D-15: Token surfacing: a small
internal/web/ui(orinternal/auth/csrf) templ helper component renders the hidden input — e.g.@csrf.Field(ctx)→<input type="hidden" name="_csrf" value="...">. 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, usinggolang.org/x/time/rate. Config: 5 requests per minute per key (rate = 5/min, burst = 5). Lookup mapmap[string]*rate.Limiterguarded bysync.Mutex(orsync.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.RealIPis already in the Phase 1 middleware stack — read fromr.RemoteAddrafter 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 Requestswith 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 toPOST /loginonly —POST /signupis 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 /loginandGET /signuprender full templ pages using the base layout. Unauthenticated POSTs render an HTMX fragment containing only the form with errors injected; authenticated success returnsHX-Redirect: /(andLocation: /+ 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.RequireAuthmiddleware checks the session context (set earlier by aResolveSessionmiddleware that runs always) and either lets the request through or returns303 See OtherwithLocation: /login(HTMX requests getHX-Redirect: /login). A separateRedirectIfAuthedmiddleware wrapsGET /loginandGET /signupto bounce already-authed users to/. - D-24: Middleware order in
NewRouterfor Phase 2:RequestID → RealIP → SlogLogger → Recoverer → ResolveSession → csrf.Protect(...) → [route groups apply RequireAuth / RedirectIfAuthed as needed].ResolveSessionreads the cookie, looks up the session+user, stuffs both into request context; never blocks — it'sRequireAuththat 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-goOR thecompose.yamlPostgres reachable throughTEST_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 (smallerm) 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 betweeninternal/auth/+internal/session/(theinternal/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
/loginand/signup— minimal, consistent with the Phase 1 design system (internal/web/uiButton/Card/Badge). One column, centered, clear labels. - Whether to introduce a
flashhelper (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.govs 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_SECRETor a dedicatedCSRF_KEY); planner makes the call but the key MUST come from env (no compile-time constants). - Whether
POST /logoutlives 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_refs>
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.tsandapps/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 newinternal/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 is0002_<name>.sql(planner picks slug) and includesCREATE EXTENSION IF NOT EXISTS citext;plususers+sessionstables.backend/sqlc.yaml— sqlc config; queries land ininternal/db/queries/, generated intointernal/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.IDKeysignature; 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
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
backend/internal/web/middleware.go(RequestIDMiddleware,SlogLoggerMiddleware): pattern for context-injecting middleware —ResolveSessionfollows 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 appendsResolveSession → 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 viaHX-Requestheader for response selection. Pingerinterface pattern (handlers depend on a small interface, not the concrete pool) — the same pattern applies to aSessionStoreinterface for auth, enabling fakes in unit tests.- sqlc config emits to
internal/db/sqlc/; queries live ininternal/db/queries/<domain>.sql. Phase 2 addsinternal/db/queries/auth.sql(orusers.sql+sessions.sql).
Integration Points
cmd/web/main.go: wires the pool intoNewRouter. Phase 2 extends the constructor to accept a*sql.DB-or-pgx-pool-backedauth.Store(or constructs it insideNewRouter). The CSRF secret comes from env (SESSION_SECRETorCSRF_KEY) read inmain.goand passed in.- Existing demo route
/demo/timedoes 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).
</code_context>
## 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 storinguser_agent/ip_address/last_seen_atcolumns. - 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.
- 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_attemptstable — 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