ADR-003: Personal plane authentication
ADR-003: Personal plane authentication
Section titled “ADR-003: Personal plane authentication”- Status: Proposed
- Date: 2026-05-29
- Deciders: Keepin’ Tracks engineering
- Supersedes: —
- Superseded by: —
Context
Section titled “Context”The personal product runs on its own Cloudflare isolation plane (personal-api, personal-web, personal-db, personal-sessions KV). Users must sign in without sharing auth infrastructure with the business plane or marketing site.
Architecture (ARCHITECTURE.md) requires:
- Magic link (MVP) and passkeys (later)
- D1 for
usersandsessions; KV for session cache and rate limits - Cookie
personal_session—httpOnly,Secure,SameSite=Lax - Single-user tenancy (
user_idon every row; noorg_id)
We evaluated three implementation paths for Cloudflare Workers + D1:
| Option | Pros | Cons |
|---|---|---|
| Better Auth | Feature-rich; D1 adapter exists | Large bundle; Node/API surface not always Worker-native; magic-link + cross-subdomain cookies need careful config |
| Auth.js | Popular | Primarily Next.js-oriented; Workers adapter immature for our Hono SPA + API split |
| Custom Hono routes | Full control; minimal deps; Web Crypto native; easy to test pure functions; mirrors future business stack | We own security maintenance |
Decision
Section titled “Decision”Implement custom Hono auth routes in apps/personal-api using Web Crypto (SHA-256 token hashing, crypto.randomUUID IDs). No shared packages/auth — business will copy the pattern with its own secrets and cookie name.
Session model
Section titled “Session model”- Opaque session ID in
personal_sessionhttpOnly cookie - D1
sessionstable is source of truth - KV cache (
session:{id}) with TTL aligned to session expiry; invalidated on logout - 30-day sliding expiration —
last_seen_atandexpires_atupdated on authenticated requests
Magic link flow
Section titled “Magic link flow”POST /auth/magic-link— validate email, rate-limit (KV), upsert user, store hashed token in D1, send email- User clicks link →
GET /auth/magic-link/verify?token=…— constant-time hash lookup, single-use (used_at), create session, set cookie, redirect toWEB_ORIGIN - Token TTL: 15 minutes
- Rate limit: 5 requests / hour / email (KV counter)
Email delivery
Section titled “Email delivery”| Environment | Mechanism |
|---|---|
| Local dev | Console log + GET /dev/magic-link/latest (gated to ENVIRONMENT=development) |
| Staging / production | Cloudflare Email Service (binding TBD; sender domain must be verified) |
DNS and cookies
Section titled “DNS and cookies”| Environment | Web | API | Cookie Domain |
|---|---|---|---|
| Local | http://localhost:5173 | http://localhost:8787 (Vite proxy — same origin to browser) | omitted |
| Production | https://personal.keepintracks.com | https://api.personal.keepintracks.com | .personal.keepintracks.com |
CORS allowlist uses WEB_ORIGIN with credentials: true. SPA calls API with credentials: 'include'.
Security
Section titled “Security”- CSRF:
SameSite=Laxon session cookie; state-changing routes use POST; magic link verify is GET but single-use and short-lived - Token storage: versioned SHA-256 digests via
@keepintracks/crypto(see ADR-004); raw token in URL only - Constant-time compare via hashed lookup (no early exit on prefix)
- Secrets:
SESSION_SECRET(personal-api only, viawrangler secret); never shared with business-api - No client secrets — SPA never sees session ID except via httpOnly cookie
Data inventory (Law 25 / PIPEDA)
Section titled “Data inventory (Law 25 / PIPEDA)”| Table / field | Purpose | Retention |
|---|---|---|
users.email | Account identity | Until account deletion |
users.created_at, updated_at | Audit | Until account deletion |
sessions.user_agent, sessions.ip_address | Security / abuse detection | Session TTL (30 days sliding) |
sessions.expires_at, last_seen_at | Session lifecycle | Auto-purged on expiry |
magic_link_tokens.email | Delivery audit | 24h after expiry (cleanup job later) |
User rights (MVP stubs):
- Export:
GET /me/export— user id, email, timestamps (Law 25 export path) - Deletion:
DELETE /me— cascades sessions, magic links, user row - Logout: immediate session invalidation in D1 + KV
Offline implications
Section titled “Offline implications”Auth requires connectivity for magic link delivery and session bootstrap. The SPA caches the last known session user in memory only; offline product features (Phase 2+) will use separate local credentials — not documented here.
Future: WebAuthn passkeys
Section titled “Future: WebAuthn passkeys”Add passkey_credentials table and /auth/passkey/* routes in a follow-up ADR amendment. Session cookie model unchanged.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Worker-native, minimal bundle, full D1/KV control
- Clear template for business-plane auth (separate cookie, secrets, migrations)
- Testable pure crypto and validation helpers
Negative
Section titled “Negative”- Team maintains auth code (no vendor SLA)
- Email Service integration required before production magic links
Follow-ups
Section titled “Follow-ups”- Cloudflare Email Service binding and verified sender domain
- Passkey (WebAuthn) support
- Account export / deletion endpoints
- Session cleanup cron (expired rows)
- Business-plane ADR mirroring this pattern
Cryptographic algorithms and post-quantum roadmap: ADR-004: Crypto agility and post-quantum.
References
Section titled “References”- ARCHITECTURE.md — Authentication
- DEPLOYMENT.md — Secrets and cookie policy
- docs/domains/personal.md