Auth and sessions

Hand-rolled authentication on the edge — Argon2id, a cached session fast-path, and one token path for the admin and the API

Authentication in Cusp is hand-rolled, small, and auditable, and it runs entirely on the Worker — there is no external identity provider to sign up for. It sits behind a single interface so the backing implementation can change without touching the routes that depend on it, and it is tuned for the constraints of a Cloudflare isolate.

The Auth interface seam

Everything the rest of the Worker needs — sign in, sign out, validate a session, change a password — is expressed as a single Auth interface in src/shared/auth/. The v1 backing is a local implementation against D1; because callers depend on the interface and never on the implementation, a future backing (a managed service, a different store) can be swapped in without rewriting routes. OAuth and external identity providers are deliberately out of core and left to plugins (ADR 0009).

Passwords

Passwords are hashed with Argon2id at OWASP 2024 option 2 — 19 MiB of memory, two iterations, one lane — via @noble/hashes, landing at roughly 150 to 300 ms per verify. That cost is tuned to the isolate's memory and its CPU ceiling: high enough to be a real barrier, low enough to fit the platform. The parameters are encoded into the stored hash, so the cost can be raised later and rotates per user through a lazy rehash on their next sign-in — no bulk migration. Nothing is ever stored in plaintext.

Sessions and the token fast-path

A session is stored in D1 and carried as a cookie that is httpOnly, secure, and sameSite=lax. It uses a sliding 30-day window: once a validated session has burnt through the 5-day slide threshold, expires_at is extended to 30 days from now and the cookie is re-issued, so an active user is never signed out mid-use while an abandoned session still lapses (ADR 0062). Headless API tokens never slide — their expiry is owner-chosen, audited state. There is no absolute upper bound on the sliding window yet — an expires_at_max cap is a v1.x follow-up. The stored credential is hashed, so a database leak does not yield usable tokens.

There is a performance tension here: every authenticated request has to validate the session token, but Argon2id is deliberately slow — running it on each request would tax the whole admin. Cusp resolves it with a layered check. The token's id keys a KV cache entry (apiToken:{id}, a one-hour TTL) that carries a SHA-256 digest of the token's secret. The warm path compares that digest against the request's secret in constant time and, on a match, skips Argon2id entirely; only the cold path — a cache miss — runs the full verify and writes the cache. Revoking a token, signing out, or changing a password invalidates the entry. The result is a sub-millisecond warm path without weakening the at-rest hashing.

One auth path for the admin and the API

The admin UI and the headless API authenticate through the same code. An admin session is an entry in the same api_tokens table a headless token lives in (ADR 0026): the session cookie carries a token that holds the wildcard scope, so it satisfies every check, while a minted API token carries narrower scopes and is sent as a bearer credential instead of a cookie. Collapsing the two onto one mechanism means there is a single place where a credential is validated and a single scope check at each endpoint, rather than two parallel auth systems to keep in step.

Authorisation

Authorisation is role-based and default-deny. Every admin route carries an explicit role check — there is no middleware that silently grants access, so a missing check is a visible bug rather than a quiet inheritance. Site administration requires Administrator; content and media admit Administrator, Editor, and Author; publishing narrows to Administrator and Editor. The finer-grained (resource, action) permission set each role is seeded with is the basis for per-collection and per-document enforcement in a later release; in v1 the role at the route is the gate. The roles and permissions reference has the full matrix.

Rate limiting

The sensitive entry points — sign-in, password reset, and the collaboration WebSocket upgrade — are guarded by the RateLimit Durable Object, which keeps sliding-window counters in transactional storage. Email addresses are hashed inside the counter keys so personal data never lands in the limiter. A tripped limit returns 429 with a Retry-After header. Putting the counters in a Durable Object gives a single authoritative tally per key without a race between isolates.

Next: Versioning and time travel