All concepts
03 — Concept

Authentication

Sign in with nothing configured: magic links, Google or Microsoft, and API keys, all over sessions stored as database rows.

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

ThingProtected by
magic-link and invitation tokensno key at all: 256 random bits in the URL, only the SHA-256 hash in the row
session cookies and tenant API keysthe 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 keysOAUTH_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.