DocsModules and features
#The login
Every app built on the harness needs people to sign in, and none of them should own a password table. This service is where those logins happen, and it runs once per app: on the app's own domain (auth.<app-domain>), with the app's name, logo and colours on every page and mail, its own passkey relying party, its own cookies, sender, database and secrets. Nothing on an instance names any other app, or the harness.
Six ways in, one session. Passkeys, Google, Apple, Meta, email and password, magic links. Whichever a person picks, the result is the same session, and one person can use all six on one account.
An instance runs one of two ways:
- Self-hosted. The app's team deploys and operates the instance itself (the EarthOS pattern:
auth.earthos.world). - Managed by Cratefield. Cratefield operates the same instance on the app's domain (for example
auth.alphahunt.ing). Cratefield's own app usesauth.cratefield.com. The instances Cratefield operates are the directories underinstances/.
Either way it is the same, unmodified Worker (crates/auth-worker): one Worker, one D1 database, harness modules that see only ports. Everything that makes an instance somebody's is configuration, and a missing piece of it is refused at boot rather than filled in with another app's value. The crates are cratefield-auth-*; only cratefield-auth-client is published (harness ADR 0011, amended 2026-10-05).
| Read | For |
|---|---|
| MANAGED-INSTANCES.md | Standing up a new instance: D1, domain, secrets, Owlpost, Turnstile, OIDC callbacks, client registration |
| DEPLOYING.md | The deploy workflow, the configuration surface, rotating signing keys and client secrets |
crates/auth-worker/README.md | Every configuration key |
| ARCHITECTURE.md | How it fits the harness, and why |
| WEB-APPS.md | @cratefield/auth for TypeScript web apps |
| MIGRATING.md | Importing an existing userbase |
| META-APP-REVIEW.md | Getting an instance's Meta app through review |
#How an app uses it
- Stand up the app's instance (MANAGED-INSTANCES.md).
- Register the app as a client of it: an id, a secret, and an exact list of redirect URIs. No wildcards.
- Send people to
/authorizewith PKCE. They log in on the instance (auth.<app-domain>), by any method the instance enables. - Exchange the code at
/tokenfor a short-lived ES256 access token and a single-use refresh token. - Verify tokens locally with
cratefield-auth-clientagainst the instance's issuer (https://auth.<app-domain>), which fetches and caches the published JWKS and checks the audience so nobody has to remember to. TypeScript web apps do the same with@cratefield/auth; see WEB-APPS.md.
#Branding
Each instance sets its own (AUTH_BRAND_*, full list in the Worker's README): the display name used in page titles and every mail subject, an optional logo, accent colour, support address, footer line, and privacy and terms links. The passkey RP name defaults to the display name. The session cookie is the neutral __Host-session, host-locked to the instance. Login mail goes through the harness Mailer port, with Owlpost as the recommended adapter and Resend as an option.
#Modules
| Module | What it owns |
|---|---|
auth-core | schema, clients, sessions, tokens, the authorization flow, account linking |
auth-passkeys | WebAuthn registration and login |
auth-oidc | Google and Apple: discovery, PKCE, ID tokens, minted Apple client secret, form_post callback |
auth-meta | Facebook Login: OAuth 2.0 plus a Graph profile call, with no OpenID Connect anywhere, and Meta's data deletion callback (META-APP-REVIEW.md) |
auth-password | Email and password — argon2id, a per-account lockout, a breach check, and answers that reveal nothing about who has an account. Needs the paid Workers plan (ADR 0200) |
auth-magic-link | Sign in by email: a single-use bearer credential, the way an address gets verified, and the way back in for a locked or passwordless account |
#Bringing an existing userbase
Running on another auth service today? MIGRATING.md walks a Supabase export through fz auth import, keeping each user's password and verified email.
#The login chooser
/v1/auth-core/authorize renders a sign-in page whenever there is no session. Which buttons it offers is configuration, not discovery:
AUTH_CORE_LOGIN_METHODS=passkey,google,apple,metaOrder is display order. An unknown slug fails validate_config, which today means cargo test catches it: nothing on the production boot path calls that check, so at runtime an unknown slug is silently dropped and simply renders no button. Setting a provider's credentials does not by itself put it on the chooser: which methods a deployment offers is a decision.
Redirect-shaped methods are plain links and work with script switched off. A passkey cannot be: a WebAuthn credential is bound to a relying-party id, and a browser only runs a ceremony on a page whose origin matches, so a passkey registered here works on this service's own pages and nowhere else. That is why the chooser ships one small inline script, and why it ships it only when a passkey is enabled. See ADR 0203.
#Refresh tokens and the reuse grace
Refresh tokens are single-use. Presenting an already-consumed one is treated as a compromise: the session is revoked before the request is refused.
That rule is right, but it has a false positive. A browser page can fire several requests at once, and on Workers each lands in its own isolate holding the same refresh cookie. If two of them refresh together, the loser presents a token the winner has just consumed — and a session nobody attacked is revoked. A short grace closes that hole:
AUTH_CORE_REFRESH_REUSE_GRACE_SECONDS=20 # default 0: off, today's behaviour
AUTH_CORE_REFRESH_REUSE_GRACE_MAX_USES=3 # graced reuses one token gets (1..=10)Inside the window (measured from the original rotation, never extended by a graced reuse) a second presentation by the same client id succeeds: the consumed token gains a sibling successor instead of revoking, so each racing request holds a usable token. Siblings stay live until one is used; that first use retires the rest, and the family converges on the chain the browser actually kept. A graced reuse past MAX_USES in the window, or one arriving after another sibling's chain has moved on, revokes as before. The window must be 0..=300 seconds and MAX_USES must be 1..=10; a value outside either range fails validate_config.
For a browser client, 10–30 seconds is the recommendation: long enough to cover a page's parallel requests, short enough that a token stolen and replayed later still trips the alarm. The trade-off is explicit — a stolen token used within seconds of the legitimate refresh, by the same client id, is not detected. Set the window to 0 where that risk outweighs the false positive.
#Enterprise SSO
A venture can point one organization at its own OpenID Connect provider: a connection per organization, a domain per connection, and a session that says it came in that way. Creating one, what the customer's IT admin configures, and how to require it for an organization are in SSO.md.
#Status
Design adopted 2026-09-06; per-instance model adopted 2026-10-05 (issue #777). The Worker composes all merged modules and deploys with wrangler, one instance per app. instances/alphahunt, instances/yoginini and instances/keepshipping are live on staging only (auth-staging.alphahunt.ing, auth-staging.yoginini.us, auth-staging.keepshipping.run); instances/findsyou (auth.findsyou.work) has both D1s provisioned and goes to production first, for the live FindsYou app; instances/earthos (auth.earthos.world, Earth.OS account) has both D1s provisioned too; their production deploys and instances/cratefield wait on the steps in MANAGED-INSTANCES.md (secrets, Owlpost, Turnstile, first auth-v* tag). Per-organization enterprise SSO over OpenID Connect has shipped (SSO.md); enterprise SAML SSO is deliberately deferred.
#License
MIT. Part of the Cratefield harness.
Mirrored from docs/auth/README.md at b6f50cd. The harness is the source of truth: a correction goes there first.