All concepts
03 — Concept

Authentication

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

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

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.

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.