Sessions are database 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 thirty-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.
Magic links: signing in with nothing configured
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: Google, Microsoft and any OIDC issuer from one provider registry
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 ones it ships with. Google, Microsoft and a generic OpenID
Connect provider 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.
Single sign-on with your own identity provider
Set OIDC_ISSUER and OIDC_CLIENT_ID — plus
OIDC_CLIENT_SECRET unless the client is public — and an oidc provider
appears on the login page, labelled with OIDC_LABEL. It works with any OpenID
Connect issuer: Okta, a single-tenant Entra ID, Keycloak, Auth0. The endpoints come from the
issuer's discovery document, which is refused unless its issuer matches exactly;
the flow is the authorization code with PKCE and a nonce; and the
id_token is verified against the issuer's published keys, fetching the set again
when an unknown key id appears, so key rotation needs nothing from you. Unset, nothing changes.
An identity is keyed by issuer and subject, so it needs no migration. The issuer must say the
email is verified: an email_verified of false is always refused, and
a missing one is treated as unverified unless OIDC_TRUST_EMAIL=true opts in an
issuer that controls the claim — a single-tenant Entra directory, which omits it.
AUTH_OIDC_ONLY=true makes the issuer the only way in: the login page shows a single
button for it and the Google and Microsoft routes refuse. The page never redirects on its own,
so signing in is always a click, even when the issuer still has you signed in. Magic links are
hidden rather than switched off, because invitations still use them. When the issuer publishes
an end-session endpoint, signing out signs out there too and comes back to the login page.
One issuer per deployment. The groups claim is read but not yet mapped to the
kit's groups or roles, there is no back-channel logout, and trusting the email claim trusts
every address the issuer sends — there is no per-domain rule.
What protects each kind of credential
| 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.
CSRF, rate limiting and origin checks
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: browser to terminal
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.