durable-auth

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.

npm i durable-auth
  • One Durable Object
  • Passkeys only
  • TypeScript
  • MIT

Step-up, simulated in this page

Session Runs in this page. Nothing is sent.
Fresh last proved with a passkey 0:00 ago

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 Request to a Response.

  • 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 allowSignup hook, 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.

createAuth options, their defaults and what they do
Option Default What it does
storerequiredThe Durable Object namespace.
rpNamerequiredShown 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).
rpIDrequest hostnameSet it to share passkeys across subdomains.
originsrequest originAllowed for passkeys and for POSTs.
parseProfileany object ≤ 4 KBValidate your own account data; throw AuthInputError to refuse.
allowSignupeveryone(req, { name, body }) => boolean: invite codes, a waitlist, a domain.
sendCodeoff(to, code, purpose) => Promise<void>; cloudflareEmail(...) is one. Off means no email recovery.
turnstileoff{ siteKey, verify: turnstileVerifier(secret) }, on sign-up and recovery.
rateLimitoff(key) => Promise<boolean>; rateLimitBinding(...) is one. Keys look like login:<ip>.
onDeleteAccountnoneRemove your app's data for the user before the account goes. If it throws, the account survives to retry.
waitUntilnonePass it, or recovery answers after sending and leaks timing.
reauthWindowMs5 minHow recent "recent" is for step-up.
maxNameLength64Longest 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.

The routes createAuth serves, and which need step-up
Route Does Needs
GET config{ email, turnstileSiteKey }: whether to offer recovery by email
GET sessionThe signed-in accountsession
POST register-options → register-verifyCreate an account and sign in
POST login-options → login-verifySign in, no username
POST reauth-options → reauth-verifyStep-up: confirm with one of this account's passkeyssession
GET passkeysThe account's passkeys, without key materialsession
POST passkey-options → passkey-verifyAdd a passkeystep-up
POST passkey-deleteRemove one, never the laststep-up
POST recover-start → recover-verifyRecovery by emailed code
POST email-start → email-verifySet the recovery emailstep-up
PUT profileName, and a partial profile updatesession
POST logout, POST logout-othersSign out here, or everywhere elsesession
POST delete-accountRuns onDeleteAccount, then removes the accountstep-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.

  1. Every session records when it last proved its user: at sign-up, sign-in, recovery, and each step-up.
  2. The four sensitive routes check that moment against reauthWindowMs (5 minutes). Too old, and they answer 403 { reauth: true }, before any effect.
  3. 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.
  4. 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-start says { ok: true } whether or not the email has an account, and with waitUntil the lookup and the send happen after the response.

The caps, simulated in this page

[email protected] Runs in this page. Nothing is sent.
Guesses on this codeno code
Wrong guesses today, this email0 / 10
Codes this hour, this email0 / 5

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 allowed Origin.
  • 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 allowSignup for 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

The three test suites, how to run each, and what each one proves
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