Accepted

ADR 0009 — Hand-rolled auth behind the Auth interface

Email and password authentication is a small, owned surface behind a swappable interface, with Argon2id hashing and D1-backed sessions

Decision

Cusp authenticates with email and password using a hand-rolled implementation that the rest of the codebase only ever reaches through a thin Auth interface. There is no external auth provider and no third-party auth library in the core. The implementation lives at src/shared/auth/, exposed through src/shared/auth/index.ts; application code imports from the barrel and never reaches into the backing file.

The wrapper pattern is the load-bearing design choice. The Auth interface exposes only what callers need — sign in, sign out, resolve a session, request and complete a password reset, create a user, invalidate every session for a user. Swapping the backing implementation, or extending it with OAuth provider methods, is a one-line change to the auth export. Route handlers, middleware, the setup wizard, and the admin API client are insulated from the implementation entirely.

Why

The Phase 1 auth surface — login, logout, session lookup, password reset, password change, invalidate-all-sessions — is a few hundred lines against tables Cusp controls end to end. Owning it keeps the bundle small, keeps the surface auditable, and removes a moving third-party dependency from a security-critical path. The wrapper boundary keeps every deferred decision (OAuth, two-factor, a different backing) a single-file change rather than a rewrite across every call site.

Two backings were considered and rejected as the shipped core. A wrapped third-party auth library was evaluated, but its Workers integration was unstable enough that pinning meant either absorbing breaking changes mid-build or freezing on a known-buggy release — unacceptable for auth. Hosted providers such as Auth0 or Clerk violate the platform-native principle and add a paid third-party dependency to a project whose pitch is no external services on day one. Both remain available as plugins; neither is in the core.

Password hashing

Passwords are hashed with Argon2id via @noble/hashes at OWASP 2024 option 2 parameters: 19 MiB memory cost, 2 iterations, 1 degree of parallelism, landing at roughly 150 to 300 milliseconds per verify on a Worker isolate. The parameters are encoded into the stored hash in a PHC-style format, so the cost can be raised later and rotates per user on the next successful login through a lazy-rehash path — no bulk migration.

argon2id$v=19,m=19456,t=2,p=1$<base64-salt>$<base64-hash>

A WebAssembly Argon2 build was considered but rejected on bundle-budget grounds; the pure-JS @noble/hashes Argon2id ships at a few kilobytes gzipped and matches the security commitment without the cost.

Sessions

  • Token — 32 bytes of cryptographically random data, 256 bits of entropy. The cookie carries the plaintext; the stored row holds only a hash, so a database leak does not yield usable session tokens.
  • Cookie — name __Host-cusp_session, with HttpOnly, Secure, SameSite=Lax, and Path=/. The __Host- prefix mandates Secure and no Domain. Secure stays on for localhost because browsers treat http://localhost as a secure context.
  • Lifetime — a sliding 30-day window. Sliding expiry extends expires_at to 30 days from now whenever the session is validated within 5 days of expiry, which caps the write rate at roughly once per 5 days per session. There is no absolute upper cap on the slide yet; an expires_at_max bound is a v1.x follow-up.
  • Expiry — a session is valid only while its expiry is in the future. Session lookup filters on expiry (expires_at > now), so an expired row is never resolved even before it is removed.

Password reset

Reset tokens are 32 random bytes, delivered as plaintext in the reset URL over the Cloudflare Email Service binding; the stored row holds only the hash. Tokens live for one hour and are single-use — consumed on reset, refused thereafter. The forgot-password endpoint returns an identical response whether or not the email matches, so it does not leak which addresses have accounts. A verified sending domain is a soft requirement: install proceeds and the Health page warns until it is configured, and an administrator can always reset another user's password from the user-management screen without email.

Rate limiting and CSRF

Login, password-reset requests, and session creation are rate-limited through the RateLimit Durable Object with sliding-window counters keyed per email and per IP. Emails are hashed in the keys to keep personal data out of Durable Object storage. On trip the wrapper throws a typed rate-limit error that the route translates to a 429 with a Retry-After header.

CSRF protection is SameSite=Lax plus an Origin-equals-host check on cookie-borne writes — no CSRF token. The admin SPA is same-origin with the Worker, so SameSite=Lax blocks cross-site form posts from carrying the cookie, and any cookie-authenticated write must present an Origin header matching the request host or it is rejected. Bearer-token writes skip the check: the header is the credential, so there is no ambient cookie to forge.

Consequences

  • A small, auditable auth surface that Cusp owns end to end, with the bundle well under budget.
  • Changing a password invalidates every other session for that user, including the calling session, so the user re-authenticates — the visible signal the change took effect. The session deletion runs in the same batch as the versioned user-row update.
  • Cusp owns its crypto and session handling, so it owns the corresponding CVEs; the offset is full control of a focused surface.
  • OAuth providers, two-factor, and passkeys are out of the core. Each lands as an extension of the same interface when it ships, not as a rewrite.
Read the admin sessions decision