Passkey sign-in for Cloudflare Workers, kept in one Durable Object
Accounts, passkeys, sessions and email recovery in the Durable Object's own SQLite. No auth service, no user database, nothing to configure in a dashboard.
- One Durable Object
- Passkeys only
- TypeScript
- MIT
Step-up, simulated in this page
Confirm it's you
The browser's passkey prompt: Face ID, Touch ID or Windows Hello
A session that last proved its user more than five minutes ago gets
403 { reauth: true } from a sensitive route. The client asks for
the passkey and retries once, by itself. Renaming isn't sensitive, so it never
asks. Everything here runs in this page; nothing is sent anywhere.
Every usual way to log in costs something on Workers
Cloudflare Access is a dashboard allowlist: fine for a team, the wrong shape for an app anyone can sign up to. Hosted auth like Clerk or Auth0 is another vendor, another bill, and your users live somewhere else. Auth libraries like Better Auth want a database and a schema, which on Workers means D1 and migrations before anyone can sign in.
A Durable Object already has the property sign-in needs most: it does one
thing at a time, with its own SQLite. "Check this code, then burn it" and
"check this challenge, then consume it" are atomic without transactions.
"This email belongs to one account" is a plain UNIQUE
constraint.
-
Small on purpose
One Durable Object and about 1,500 lines of TypeScript. The routes are one function from a
Requestto aResponse. -
The crypto isn't hand-rolled
Passkey verification is SimpleWebAuthn, the one part nobody should write themselves. It's the only runtime dependency, server and browser halves.
-
No username, no password
Discoverable passkeys: Face ID, Touch ID, Windows Hello or a security key. The passkey says who they are, so there is nothing to type and nothing to phish.
-
Running in production
Extracted from Rampset, an offline-first barbell app, where it opens the app's original database in place. It pairs with durable-sync.
The whole account lifecycle
-
Passkeys
Discoverable, so no username. User verification required. Single-use challenges, bound to their purpose and (for registration, adding a passkey and step-up) to the account.
-
Sessions
256-bit tokens in an
HttpOnly,SameSite=Lax,__Host-cookie. Only a SHA-256 hash is stored. Sliding one-year expiry. -
Step-up
Deleting the account, changing the recovery email, and adding or removing a passkey need a passkey used in the last 5 minutes. The client asks and retries on its own.
-
Recovery
Six-digit codes by email, 10-minute expiry, hard caps on guessing. Recovering signs every device out and revokes every passkey.
-
No enumeration
Recovery and adding an email answer the same way, with the same rate limits and (with
waitUntil) the same timing, whether or not the email is registered. -
Also
Passkey list and remove, sign out other devices, an
allowSignuphook, per-IP rate limits, Turnstile, and a daily alarm that sweeps expired rows.
A Worker, a binding, a client
Server
Extend the Durable Object and export it. Every account lives in one
instance, which is what makes an email belong to exactly one account.
auth.handle answers everything under /auth/* and
returns null for anything else.
// worker.ts
import { AuthDurableObject, createAuth, cloudflareEmail, rateLimitBinding }
from "durable-auth/server";
export class Auth extends AuthDurableObject {}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const auth = createAuth({
store: env.AUTH,
rpName: "My App",
sendCode: cloudflareEmail(env.EMAIL, { from: "[email protected]", appName: "My App" }),
rateLimit: rateLimitBinding(env.AUTH_LIMITER),
waitUntil: (work) => ctx.waitUntil(work),
});
const handled = await auth.handle(req); // everything under /auth/*
if (handled) return handled;
const account = await auth.account(req); // your routes: who's signed in?
if (!account) return new Response("Sign in first", { status: 401 });
return new Response(`Hello, ${account.name}`);
},
};
// wrangler.jsonc
{
"durable_objects": { "bindings": [{ "name": "AUTH", "class_name": "Auth" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Auth"] }],
// Optional: recovery email (onboard the domain with `wrangler email sending enable`).
"send_email": [{ "name": "EMAIL" }],
// Optional: per-IP limits on the sign-in routes.
"ratelimits": [{ "name": "AUTH_LIMITER", "namespace_id": "1001",
"simple": { "limit": 10, "period": 60 } }]
}
Onboarding a domain to Cloudflare Email Sending only adds records on a
cf-bounce subdomain, so a domain whose mail is on Gmail or
Workspace keeps working.
Client
Every call either returns what it promised or throws an
AuthError with a message fit to show the user.
authErrorMessage(e, fallback) returns null when
the user simply dismissed the passkey prompt.
import { createAuthClient, authErrorMessage }
from "durable-auth/client";
const auth = createAuthClient();
await auth.register("Sam"); // passkey prompt → account → signed in
await auth.signIn(); // passkey prompt, no username
await auth.session(); // Account | null (signed out) | undefined (offline)
await auth.startEmailVerification("[email protected]");
await auth.finishEmailVerification("[email protected]", "123456");
// On a new device, after losing the old one:
await auth.startRecovery("[email protected]");
await auth.finishRecovery("[email protected]", "123456");
await auth.addPasskey(); // recovery revoked the old passkeys
await auth.deleteAccount(); // may prompt for the passkey first (step-up)
Also: passkeys(), removePasskey(id),
signOut(), signOutOtherDevices(),
updateProfile(patch), config(),
reauth(), and passkeysSupported(). Options:
basePath (default "/auth") and
timeoutMs (default 15 s). There's a
runnable example:
a 50-line Worker and a one-page script, via npm run example.
Server options: createAuth(options)
Only store and rpName are required.
| Option | Default | What it does |
|---|---|---|
| store | required | The Durable Object namespace. |
| rpName | required | Shown in the passkey prompt. |
| basePath | "/auth" | Routes live at <basePath>/<action>. |
| cookieName | "session" | __Host- is added over HTTPS (and dropped on plain-http localhost only). |
| rpID | request hostname | Set it to share passkeys across subdomains. |
| origins | request origin | Allowed for passkeys and for POSTs. |
| parseProfile | any object ≤ 4 KB | Validate your own account data; throw AuthInputError to refuse. |
| allowSignup | everyone | (req, { name, body }) => boolean: invite codes, a waitlist, a domain. |
| sendCode | off | (to, code, purpose) => Promise<void>; cloudflareEmail(...) is one. Off means no email recovery. |
| turnstile | off | { siteKey, verify: turnstileVerifier(secret) }, on sign-up and recovery. |
| rateLimit | off | (key) => Promise<boolean>; rateLimitBinding(...) is one. Keys look like login:<ip>. |
| onDeleteAccount | none | Remove your app's data for the user before the account goes. If it throws, the account survives to retry. |
| waitUntil | none | Pass it, or recovery answers after sending and leaks timing. |
| reauthWindowMs | 5 min | How recent "recent" is for step-up. |
| maxNameLength | 64 | Longest display name accepted. |
Routes: all JSON, all under /auth
Every POST and PUT must carry an allowed Origin (or
Sec-Fetch-Site: same-origin), or it gets a 403 before any route
runs.
| Route | Does | Needs |
|---|---|---|
| GET config | { email, turnstileSiteKey }: whether to offer recovery by email | |
| GET session | The signed-in account | session |
| POST register-options → register-verify | Create an account and sign in | |
| POST login-options → login-verify | Sign in, no username | |
| POST reauth-options → reauth-verify | Step-up: confirm with one of this account's passkeys | session |
| GET passkeys | The account's passkeys, without key material | session |
| POST passkey-options → passkey-verify | Add a passkey | step-up |
| POST passkey-delete | Remove one, never the last | step-up |
| POST recover-start → recover-verify | Recovery by emailed code | |
| POST email-start → email-verify | Set the recovery email | step-up |
| PUT profile | Name, and a partial profile update | session |
| POST logout, POST logout-others | Sign out here, or everywhere else | session |
| POST delete-account | Runs onDeleteAccount, then removes the account | step-up |
A cookie is a session, not the person
A stolen cookie or an unlocked phone left on a table is signed in. Step-up is what stops it from deleting the account, taking over the recovery email, or adding a passkey of its own.
- Every session records when it last proved its user: at sign-up, sign-in, recovery, and each step-up.
- The four sensitive routes check that moment against
reauthWindowMs(5 minutes). Too old, and they answer403 { reauth: true }, before any effect. - The client's sensitive calls catch that, run
reauth-options→ passkey prompt →reauth-verify, and retry once. The prompt only offers this account's passkeys, and the challenge is bound to it. - Because the gate runs before anything changes, the single retry is safe. Callers never see the 403.
Just after recovery the account has no passkeys left to confirm with, so the emailed code counts: the recovered session is fresh for the window, which is the time it has to add one. After that, adding a passkey means recovering by email again.
Your own sensitive routes can do the same.
auth.session(req) returns verifiedAt; answer
403 { reauth: true } when it's too old, and on the client call
auth.reauth() and retry.
const s = await auth.session(req);
if (!s) return Response.json({ error: "Sign in" }, { status: 401 });
if (Date.now() - s.verifiedAt > 5 * 60_000)
return Response.json({ reauth: true }, { status: 403 });
Lost every device? An emailed code
An optional, verified recovery email gets someone back in after losing every
device. Leave sendCode out and recovery is off: then it means a
second passkey on another device, or a synced one.
- Six digits, 10 minutes. Rejection-sampled so every code is equally likely; stored only as
SHA-256(purpose:email:code). - 5 guesses a code. Then the code is dead.
- 10 wrong guesses a day per email, across every code sent to it, so asking for a new one doesn't reset the count.
- 1,000 wrong guesses a day across every email. About one chance in a thousand a day of any account falling to guessing, at an attacker's full effort.
- 5 codes an hour per email, whoever asks for them.
- Recovering is a reset. Every session is signed out and every passkey revoked, including the one on a stolen phone. The client adds a fresh passkey next.
- Same answer either way.
recover-startsays{ ok: true }whether or not the email has an account, and withwaitUntilthe lookup and the send happen after the response.
The caps, simulated in this page
Send a code, then try to guess it. The server's answer to a wrong guess, an expired code and a locked email is the same sentence.
What it defends, and what it doesn't do
Defends against
- A stolen database. It holds only hashes of session tokens and codes, plus passkey public keys. None of them is a credential.
- Phishing. Passkeys are bound to your domain by the browser. There's no password to type into the wrong site.
- A stolen cookie or a borrowed, unlocked phone. Step-up stops it deleting the account, taking the recovery email, or adding its own passkey.
- A stolen phone. Recover by email on another device: every session signed out, every passkey revoked.
- Guessing codes. 5 a code, 10 a day per email, 1,000 a day in all.
- Learning who has an account. Recovery and email verification answer the same way, at the same speed, either way.
- CSRF.
SameSite=Lax, and every POST must carry an allowedOrigin. - Bots signing up. Per-IP rate limits, optional Turnstile, and
allowSignup.
Doesn't do
- Organisations, roles or invitations. It knows accounts, not tenants. Keep those in your app, keyed by account id; use
allowSignupfor invites. - OAuth, social login or passwords. Deliberately.
- Protect a weak inbox. The email account is the recovery factor: whoever controls it can recover the account.
- Stop a stranger pausing recovery. Ten wrong guesses lock one email's recovery for a day, and a flood locks everyone's. Rate limits and Turnstile keep that expensive; it's the trade for codes that can't be brute-forced.
- Guard the profile. It's the user's to write. A plan or a role belongs in your own storage, or gets refused in
parseProfile. - Scale without limit. One Durable Object serves every account, and
auth.session(req)is a round trip to it. Plenty for thousands of active users; past that, cache the session in your Worker for a few seconds.
Need orgs, SSO, social login or a hosted UI? Use something built for that. Read the honest comparison with Cloudflare Access, Better Auth, Clerk, Auth0 and Lucia. Found a hole? Report it privately.
Real passkeys, real Durable Object
| Suite | Run it | What it proves |
|---|---|---|
| Unit | npm test |
The store on Node's node:sqlite, and every route against it, with the passkey cryptography stubbed. |
| Integration | npm run test:integration |
The real Durable Object on workerd's own SQLite, via @cloudflare/vitest-plugin: BLOBs, NULL bindings, the schema ALTERs, RPC. |
| End-to-end | npm run test:e2e |
The example app in Chromium with Chrome's virtual authenticator, one per device, so the WebAuthn is real. The journeys are below. |
- a passkey is the whole login: sign up, stay signed in, sign out, sign back in
- a lost device: back in by email on a new one, and the lost one is locked out
- a recovery email for an address nobody uses looks exactly the same
- deleting the account asks for the passkey again once the session is stale
- the only passkey can't be removed
- signing out other devices keeps this one