Sessions are rows, not tokens
A signed-in browser holds one cookie, __Host-session, whose value points at a row in
user_sessions. The row has a seven-day sliding lifetime; the cookie is
HttpOnly, SameSite=Lax, carries no Domain, and is
Secure everywhere except local development. The __Host- prefix is what
makes the browser refuse a cookie that tries to widen its own scope.
Every request resolves session, user, membership and abilities in one LATERAL query,
then does its bookkeeping — touching last_used, sweeping expired rows — inside
waitUntil, so the work happens after the response has already gone out. The second
strategy is a bearer Authorization header carrying a hashed tenant API key, checked
for expiry and soft revocation. Both strategies converge on the same authenticated context, so
nothing downstream knows or cares which one was used.
Why rows rather than a self-contained token. A JWT cannot be revoked without
building the very lookup table it was meant to avoid. Sessions in Postgres mean "log out
everywhere" is a DELETE, a suspended tenant takes effect on the next request, and
there is one place to look when somebody asks who was signed in. The cost is a query per
request, which is the same query already fetching the membership and abilities.
The magic link is the zero-credential path
A magic link carries 256 random bits — an unguessable token, not a signed one — valid for
fifteen minutes, usable once, and stored as a SHA-256 hash rather than in the clear, so a copy of
the database cannot be replayed as a login. Nothing about it depends on an email provider being
configured: without RESEND_API_KEY the URL is written to the log instead of sent, so
a freshly cloned repository can be signed into with no accounts, no keys and no external service
at all.
This is also why the magic-link email is the one message the kit sends inline rather than queueing. Somebody is sitting in front of a login screen waiting for it; latency beats offloading.
A development login route exists alongside it for convenience and returns a 404 in production —
it is not gated by a flag that somebody can set wrongly, it simply is not there. In development,
/login?as=<email> signs straight in as one of the seeded demo accounts through
that route — it is what the bootstrap opens — and the page honours it only when the server
reports that dev login is available and the address is on the seeded allow-list; any other
address in the URL does nothing.
OAuth is a registry, not a copy-paste
Rather than a file per provider, there is one generic pair of routes —
/auth/:provider and /auth/:provider/callback — over a list of provider
definitions. The OAuth client underneath is
arctic, which already speaks to more than fifty
providers — GitHub, GitLab, Slack, Apple, Okta, Auth0, Discord, LinkedIn, Keycloak and the rest —
so the kit is not limited to the two it ships with. Google and Microsoft are wired; adding
another is one definition file with its arctic client, scopes and profile mapping, and no new
routing.
Redirect URIs are derived from APP_URL, so there is no
GOOGLE_REDIRECT_URI-style variable to keep in step across three environments — the
one that is wrong in staging and right in production, and only discovered by a user. A single
oauth_state cookie carries both the provider and the PKCE state.
Account linking is by verified email only, and a provider that reports
email_verified: false is refused outright. A pair of provider and provider-user-id is
unique, so the same external account cannot be attached to two people.
What protects what
| Thing | Protected by |
|---|---|
| magic-link and invitation tokens | no key at all: 256 random bits in the URL, only the SHA-256 hash in the row |
| session cookies and tenant API keys | the same shape — a random token in the client's hands, its hash in the database; an API key's plaintext is shown once |
| OAuth tokens and tenant AI provider keys | OAUTH_ENCRYPTION_KEY — AES-GCM encryption at rest |
There is one secret to manage, and it is an encryption key rather than a signing key. Nothing on
the login path is signed, so there is no key whose theft lets somebody forge a login — a token is
either the random value that was issued or it is not, and a stolen database holds only hashes.
The one key that does exist is never derived from the database URL, which would tie the ability
to read a connection string to the ability to read every stored credential. Random material comes
from crypto.getRandomValues, and there is no plaintext pass-through when the
encryption key is missing — the operation refuses instead. Rotating it invalidates every
stored provider credential, since there is no re-encrypt path; administrators re-enter the key.
Hardening at the edges
CSRF is enforced by an origin allow-list built from APP_URL plus the local
development ports, and bearer-authenticated requests are exempt because they cannot be made
ambiently by a browser. Login routes sit behind a sliding-window rate limit in Workers KV, which
also backs a per-tenant single-flight lock for operations that must not run twice concurrently.
Security headers, a body limit and a secret scanner in CI round it out.
The KV rate limiter is approximate by design: eventually-consistent counters trade exactness for a global limit that costs nothing on the request path and degrades to a no-op when the binding is absent. Where an exact count matters more than availability, Cloudflare's rate limiting binding is the swap, and it goes in the same place.
The CLI login handoff
GET /auth/cli is the single bridge between a browser session and a terminal. It takes
a redirect_uri, and that URI must be exactly
http://127.0.0.1:<port>/callback or the localhost equivalent — any
port, http only, no query string, no fragment, no userinfo. Anything else is a 400.
That allow-list is the whole reason it is acceptable to hand a key over in a query string: the redirect can only ever reach a listener on the machine the person is sitting at. Without a session the route bounces to the login page and comes back; without a tenant it asks which organisation (skipped entirely in single-tenant mode); then it mints a tenant API key named after the machine, through the same helper the settings screen uses, and redirects home with it.
The key is therefore an ordinary tenant key: visible in Settings, revocable there, and logged in the activity feed when it was created. It is shown exactly once. There is no refresh — if you lose it, revoke it and log in again.