cloudflare-workers-passkey-auth
Skill0 starsChanged 5 days agoMITView on GitHub
Add passkey-only (WebAuthn) closed registration + revocable sessions to a Cloudflare Workers app (Hono + D1 + Drizzle) for a family-scale app — @simplewebauthn v13, a signed 5-minute challenge cookie (no D1 challenge table), an HS256 session JWT backed by a sessions table (revocable, 30-day sliding), and a public register endpoint dispatching initial-token vs space-invite registration. Use when building login for an invite-only app without passwords or OAuth, when "every iPhone user gets 401" on login (counter-regression check without the counter=0 exemption for synced passkeys), when all passkeys died after a domain/subdomain change (RP_ID lock), when startRegistration throws NotAllowedError / InvalidStateError / SecurityError, or when the session cookie must not leak to sibling *.workers.dev Workers (host-only, __Host- prefix). Covers schema, routes, dev-bypass twin guard, CSRF Origin check, auth rate limits, INITIAL_REGISTRATION_TOKEN cycle, SESSION_SECRET rotation, lost-passkey recovery.
- Reads credentials
In this repository23 other files it gives its agents.
- skills/vercel-react-best-practices/AGENTS.mdAGENTS.md
- agent-status-hubSkill
- claude-code-docker-sandboxSkill
- cloudflare-api-token-permissionsSkill
- cloudflare-cron-to-discordSkill
- cloudflare-d1-drizzle-migrationSkill
- cloudflare-d1-keyless-host-backupSkill
- cloudflare-d1-weekly-backup-via-prSkill
- cloudflare-mcp-claude-toolingSkill
- cloudflare-r2-private-image-uploadSkill
- cloudflare-workers-bot-scan-defenseSkill
- cloudflare-workers-builds-keyless-deploySkill
- cloudflare-workers-deploy-skeletonSkill
- cloudflare-workers-e2e-playwrightSkill
- cloudflare-workers-host-synthetic-checkSkill
- cloudflare-workers-pat-bearer-authSkill
- cloudflare-workers-space-membership-inviteSkill
- cloudflare-workflows-for-long-tasksSkill
- playwright-e2e-in-docker-sandboxSkill
- sandboxed-agent-github-token-via-1passwordSkill
- sandboxed-agent-git-relaySkill
- snr-writingSkill
- vercel-react-best-practicesSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 23
every iPhone / iCloud-Keychain user gets 401 on login while Android/desktop works → signature-counter check without the counter === 0 …
- Very specific: line 24
all registered passkeys stopped working after a hostname change (account-subdomain rename, custom domain) → RP_ID lock broken
- Distinctive: line 30
Do not use for:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Cloudflare Workers Passkey Auth (closed registration, revocable sessions)
Get a Hono + D1 Worker to the state where the only way in is a passkey, registration is closed except through a one-shot bootstrap token (or a space invite), and any session can be killed server-side — with zero password, zero external IdP, and zero D1 table for WebAuthn challenges.
Extracted from nyalog (ADR-003, PR-level code) and routine-tasks (Phase 4a/4b walkthrough), 2026-08-22. Cookie/CSRF details from mazuoboeru.
Why this shape: a private app used by a handful of known people (a family) doesn't need sign-up, password reset, or a login screen with an email field. What it needs is phishing resistance (RP-ID-bound passkeys), instant revocation (a sessions row, not a bare JWT), and a registration door that is shut by default. Every piece here exists because the naive version (JWT-only sessions, secure: true hardcoded, a strict signature-counter check, a Domain= cookie) broke in production in one of the source projects.
When to use this skill
- Building login for an invite-only app (family, household, 2–10 people) on the
cloudflare-workers-deploy-skeletonstack, and you've decided against OAuth / Cloudflare Access (decision table below) - Symptoms you are hitting:
- every iPhone / iCloud-Keychain user gets
401on login while Android/desktop works → signature-counter check without thecounter === 0exemption - all registered passkeys stopped working after a hostname change (account-subdomain rename, custom domain) → RP_ID lock broken
startRegistration/startAuthenticationthrowsNotAllowedError,InvalidStateError, orSecurityError→ see the client-flow reference- a session cookie shows up on a different Worker under the same
*.workers.devsubdomain →Domain=attribute / not host-only - the login endpoint returns
401even though it should be public → protected sub-app mounted before the public auth routes
- every iPhone / iCloud-Keychain user gets
- Adding "add this device as a passkey" / "revoke that device" management to an existing passkey app
Do not use for:
- A public SaaS with self-service sign-up — use OAuth (mazuoboeru's
arcticsetup) and the seeded-session e2e seam incloudflare-workers-e2e-playwright; passkeys there are a Phase-2 add-on, not the front door - Apps that must stay behind Cloudflare Access (allowlist-by-dashboard) — different threat model, nothing here applies
- Password or magic-link auth — none of the challenge/counter/RP_ID machinery transfers
Decision: why passkeys here (and when not)
| Option | Family-scale private app | Why it lost / won (source) |
| Cloudflare Access (Google IdP) | ✗ | OTP/SSO round-trip on every mobile open; authz lives in the dashboard, no in-app logout/device management (nyalog ADR-002 → ADR-003) |
| Google OAuth | ✗ | Per-project Google Cloud console work (OAuth client + consent screen) that doesn't scale across hobby projects (mazuoboeru ADR-0001 addendum) |
| GitHub OAuth | ✗ | Family members don't have GitHub accounts — developer-only audience |
| Passkeys (WebAuthn) | ✓ | Login = unlock gesture; RP-ID binding = phishing resistance; no shared secret server-side; closed registration is natural |
| Auth SaaS / Better Auth / Lucia | ✗ | Lucia deprecated 2025-03; the rest are oversized for ~4 users and add classes/lock-in |
Constraints you accept with passkeys: RP_ID is permanent (see below), each person should register two authenticators (no password reset exists), and a lost-everything recovery needs an operator (runbook in references/ops-and-recovery.md).
Deliverables (completion criteria)
users/credentials/sessionstables migrated (Drizzle + SQL inreferences/schema.md)POST /api/auth/register/begin|verifyis public and dispatchesinitial(one-shot secret) vsinvite(space token) inside the handler; returns403 registration_closedwhenINITIAL_REGISTRATION_TOKENis unsetPOST /api/auth/credentials/add/begin|verify(add a device) sits behindsessionMiddleware()and usesexcludeCredentialsPOST /api/auth/login/begin|verifyis username-less (residentKey: "required", noallowCredentials); verify passesrequireUserVerification: false- WebAuthn challenge lives in a signed 5-minute cookie (
hono/jwtoverSESSION_SECRET) carrying the registration state; nochallengestable - Session = HS256 JWT with
sid+ asessionsrow; logout deletes the row; expiry slides (30 days) - Cookies are host-only (no
Domain),HttpOnly,SameSite=Lax,Path=/;secureand the__Host-prefix are derived from theORIGINscheme, never hardcoded - Non-GET
/api/*requests requireOrigin === ORIGIN(403 csrf_origin_mismatch) - The 4 unauthenticated auth routes are IP-rate-limited (
CF-Connecting-IP), config percloudflare-workers-bot-scan-defense DEV_BYPASS_USER_IDis honored only whenORIGINis a localhost origin;wrangler secret liston prod never shows itRP_ID/ORIGINare pinned inwrangler.jsoncvarsto the final production hostname before the first passkey is registered;.dev.varsoverrides them locallyDELETE /api/auth/credentials/:idrefuses to delete the user's last credential (400 last_credential).dev.vars.examplelistsSESSION_SECRET,RP_ID,ORIGIN,INITIAL_REGISTRATION_TOKEN,DEV_BYPASS_USER_ID(keys only)- Every
UNVERIFIED:bullet below has been checked on the real app and the result written back into this skill
Architecture in one screen
Browser ──HTTPS + cookies──▶ Worker (Hono)
├─ secureHeaders (CSP/HSTS — deploy-skeleton)
├─ csrfOriginCheck on /api/* (non-GET: Origin === ORIGIN)
├─ /api/auth/{register,login}/* PUBLIC + authRateLimit
├─ sessionMiddleware (JWT → sessions row → c.var.userId)
└─ everything else under /api (domain routes)
D1: users · credentials (public keys) · sessions (revocation)
@simplewebauthn/server: verify{Registration,Authentication}Response| Layer | Owns | Must not do |
| WebAuthn ceremony | "is the holder of this authenticator real" | sessions, authorization |
| Challenge cookie | one-shot state between begin and verify (signed, 5 min) | outlive the ceremony |
| Session JWT | carry sid to the server with a signature | carry permissions |
sessions table | truth about "is this sid alive" | duplicate JWT claims |
sessionMiddleware | authenticate + load c.var.userId (+ space memberships, sibling skill) | decide per-resource access |
Schema — three tables
users(id, display_name, created_at), credentials(id = WebAuthn credential id base64url, user_id, public_key, counter, transports JSON, device_name, backed_up, created_at, last_used_at), sessions(id = sid, user_id, expires_at, created_at). public_key is stored as base64url TEXT (nyalog), not BLOB (routine-tasks): readable in SQL dumps/wrangler d1 execute output, no Drizzle blob-mode typing questions; the 4/3 size inflation on a ~80-byte key is irrelevant. Both child tables cascade on user delete. Full Drizzle + SQL and the migration-order note in references/schema.md.
Design NOT NULL columns from day one — users is a parent with ON DELETE CASCADE children, and a later table-rebuild migration on it is exactly the D1 cascade-delete trap in cloudflare-d1-drizzle-migration.
Challenge cookie and session cookie
- Challenge:
generate*Options()→ put{ challenge, state, aud: "app:challenge", exp: now+300s }into a signed cookie →verifyreads and deletes the cookie before validating it (single use).stateis a discriminated union:authentication|add-credential{uid}|initial{uid, displayName}|invite{uid, displayName, inviteId, spaceId}. Because the state is signed server-side,verifynever trusts client-supplied ids. (routine-tasks round-tripsregistration: {kind, pendingUserId, inviteId, spaceId}through the client body unsigned; an invite holder could then pickspaceId— keep it in the cookie.) - Session:
issueSessioninserts asessionsrow (sid = crypto.randomUUID(),expires_at = now+30d) and sets the JWT{ sid, aud: "app:session", exp }.sessionMiddlewareverifies the JWT, loadssessions ⋈ usersbysid, rejects missing/expired rows (401 session_expired, cookie cleared), setsc.var.userId/c.var.displayName, and slides the expiry when less than half remains (one DB write per ~15 days per session; mazuoboeru's alternative is "refresh at most once a day"). - Cookie attributes (both cookies):
HttpOnly,SameSite=Lax,Path=/, noDomain— sibling Workers share<account>.workers.dev, so aDomain=cookie is readable/tossable across your other apps. Name is__Host-sessionwhenORIGINstarts withhttps://(browser-enforced Secure + Path=/ + no Domain), plainsessionotherwise;secureisORIGIN.startsWith("https://"). Hardcodingsecure: true(nyalog) makes the cookie silently vanish onhttp://127.0.0.1e2e runs. Deleting uses the same attribute set (deleteCookie(c, name, cookieBase(c))): Hono throws on a__Host-name withoutsecure, and a browser ignores a non-Secure__Host-Set-Cookie, so{ path: "/" }alone leaves the session cookie alive on https (verified 2026-08-30 in matatabetai). - One
SESSION_SECRETsigns both JWTs; the distinctaudvalues stop a session token from being replayed as a challenge and vice versa.
Templates: references/session-and-challenge.md.
Routes and the public register dispatch
| Route | Auth | Purpose | |
POST /api/auth/register/begin | public + rate limit | Dispatch: body has inviteToken → validate invite (sibling skill) ; else initialRegistrationToken must equal the INITIAL_REGISTRATION_TOKEN secret, else 403 registration_closed. Issue challenge with {kind, uid: fresh UUID, displayName, …} | |
POST /api/auth/register/verify | public + rate limit | Consume challenge, verifyRegistrationResponse, then ONE db.batch: users + credentials (+ spaces/space_members/invites UPDATE per the sibling skill) → issueSession | |
POST /api/auth/login/begin | public + rate limit | generateAuthenticationOptions({ rpID, userVerification: "preferred" }) — no allowCredentials → browser shows the passkey picker | |
POST /api/auth/login/verify | public + rate limit | Look up credentials by response.id, verifyAuthenticationResponse, store newCounter + last_used_at, issueSession | |
POST /api/auth/logout | session | delete the sessions row, clear cookie | |
GET /api/auth/me | session | { id, displayName } (+ memberships, sibling skill) | |
GET /api/auth/credentials | session | list devices (deviceName, backedUp, lastUsedAt) | |
| `POST /api/auth/credentials/add/begin | verify` | session | add a device; excludeCredentials = user's existing ids; challenge state { kind: "add-credential", uid } must match c.var.userId |
DELETE /api/auth/credentials/:id | session | scoped to user_id; 400 last_credential when it's the only one |
Why register/* is public and dispatches inside the handler: the caller is, by definition, not logged in (initial owner or invited newcomer). routine-tasks also folded the "add a device" path into the same public endpoint by re-verifying the session cookie inline; mounting credentials/add/* behind sessionMiddleware() (nyalog) is the same thing with less duplicated verification code — do that.
Mount order trap (Hono): register the public /auth routes on the api app before api.route("/", protectedApi); protectedApi.use("/*", sessionMiddleware()) matches every /api/* path, and a later-registered handler only wins because the earlier public handler returned first. Swap the two lines and login/begin answers 401.
Full handlers, csrfOriginCheck, authRateLimit, and worker/index.ts in references/auth-routes.md.
Login: username-less + the counter-regression exemption
residentKey: "required"at registration + noallowCredentialsat login = one "Log in" button, no identifier field; the authenticator lists its passkeys for this RP_ID.userVerification: "preferred"+requireUserVerification: falseon verify: biometrics when available, no hard failure on authenticators without UV UX.- Signature counter: store
authenticationInfo.newCounterafter every login. If you write your own regression guard, it must beif (stored !== 0 && newCounter <= stored) reject— synced passkeys (iCloud Keychain, Google Password Manager) report0forever, and a strict<=check locked out every iPhone in the family in routine-tasks.@simplewebauthn/serveralready applies the same "only when either counter > 0" rule insideverifyAuthenticationResponse(see UNVERIFIED), so the explicit guard is belt-and-braces. - Store
credentialBackedUp(backed_up) and show it in the device list — it tells the user which passkeys survive a lost phone.
RP_ID: locked on the first registration
RP_ID (hostname, no scheme/port) and ORIGIN (scheme + host [+ port]) are credential-binding inputs. Changing RP_ID invalidates every registered passkey; expectedOrigin mismatch fails every ceremony.
- Pin both in
wrangler.jsoncvarsto the final production hostname before anyone registers. If a custom domain is ever planned, move there first (mazuoboeru ADR-0001 addendum), then register. - Two real incidents: nyalog renamed its Cloudflare account subdomain (
<old>→<new>.workers.dev) and every family member re-registered; routine-tasks ADR-0002 now treats any diff toRP_ID/ORIGINin a PR as an automatic reject. - Local:
.dev.varsoverridesRP_ID=localhost/ORIGIN=http://localhost:5173(gitignored, cannot leak to prod).cloudflare-workers-deploy-skeletonstates the rule; this skill owns the consequences and the forced-change procedure inreferences/ops-and-recovery.md.
Dev bypass: the twin guard
sessionMiddleware short-circuits to a fixed user only if DEV_BYPASS_USER_ID is set and new URL(ORIGIN).hostname is localhost / 127.0.0.1. It auto-creates that user row (and, with spaces, a fixed dev space membership — sibling skill) so local D1 is never empty. If the variable is set on a non-local origin it logs console.error and falls through to real verification. Never wrangler secret put DEV_BYPASS_USER_ID; wrangler secret list on prod must never show it. e2e golden paths must not rely on it (cloudflare-workers-e2e-playwright uses the CDP virtual authenticator).
Rate limit and CSRF
The only unauthenticated routes that do work are register/begin|verify and login/begin|verify. Key a ratelimits binding on CF-Connecting-IP and return 429 rate_limited; the wrangler.jsonc block, the binding-vs-WAF decision and the "synthetic bursts never hit 429" eventual-consistency caveat live in cloudflare-workers-bot-scan-defense. In the credential-free sandbox the binding is stripped from the e2e config (playwright-e2e-in-docker-sandbox).
CSRF: SameSite=Lax already blocks cross-site POSTs from top-level navigations; add csrfOriginCheck on /api/* — non-GET requests must carry Origin equal to ORIGIN (403 csrf_origin_mismatch). GETs are exempt, which is also what lets e2e API specs send a bare Cookie header.
Ops: tokens, secrets, recovery
- Bootstrap:
openssl rand -hex 32(note the value) →wrangler secret put INITIAL_REGISTRATION_TOKENand paste it at the prompt — never pipe the two together, the owner has to type this value (see the pitfall below) → hand the value over out-of-band → owner registers →wrangler secret delete INITIAL_REGISTRATION_TOKENimmediately. Unset secret =403 registration_closed, which also closes the deploy-then-race window. SESSION_SECRETrotation = every session and pending challenge becomes invalid (everyone logs in again); thenDELETE FROM sessionsto drop the orphans.- Lost every passkey: the operator re-opens the initial token (creates a fresh owner user; family-shared data is still reachable because authorization is per space, not per user) or pre-builds the optional recovery path that attaches a new credential to an existing
user_id. Data source for anything worse is the D1 backup / Time Travel (cloudflare-d1-weekly-backup-via-pr).
Runbooks in references/ops-and-recovery.md; the browser side (@simplewebauthn/browser v13 optionsJSON wrapper, error-code mapping, React hook) in references/client-flow.md.
e2e
Do not duplicate the recipe: cloudflare-workers-e2e-playwright owns the CDP WebAuthn.addVirtualAuthenticator helper, RP_ID=localhost in e2e vars, the "no DEV_BYPASS in the golden path" rule, and the 3-spec scope. This skill's only requirement on it: ORIGIN in the e2e vars must equal Playwright's baseURL byte-for-byte (scheme, host, port) or every verify fails with challenge_mismatch (origin mismatch) while begin succeeds.
The pitfalls that eat hours
- Strict counter check → every synced-passkey user (iPhone) gets
401. Exemptstored === 0. RP_IDdrift (subdomain rename, custom domain, staging host) → all passkeys dead. Pin before the first registration; reject diffs.expectedOrigin≠ browser origin (port, scheme,localhostvs127.0.0.1) →beginworks,verifyalways fails. CheckORIGINfirst, not the ceremony code.secure: truehardcoded → cookies silently dropped overhttp://dev/e2e. Derive fromORIGIN.Domain=cookie → readable by every Worker under your*.workers.devsubdomain. Host-only +__Host-.- Protected sub-app mounted before the public auth routes →
login/beginreturns401. - Unsigned registration state round-tripped via the client → invite holder chooses the space. Sign it into the challenge cookie.
TextEncoder.encode()typedUint8Array<ArrayBufferLike>(TS ≥ 5.7) rejected by@simplewebauthn'sUint8Array<ArrayBuffer>params → copy into a freshArrayBuffer(helper in the routes reference).- JWT-only sessions → logout/device-loss cannot revoke. Keep the
sessionsrow as the source of truth. - Forgetting
wrangler secret delete INITIAL_REGISTRATION_TOKEN→ the registration door stays open with a token sitting in someone's chat history. - Single passkey per person → a lost phone is an operator recovery. Make "add a second device" part of onboarding.
Unverified claims — confirm while implementing, then write back
Record each result in the section named; delete the bullet once it's confirmed or corrected.
- UNVERIFIED: current Safari/iOS still require
navigator.credentials.create()to be called from a user gesture (older Safari did). The client reference always calls from a click handler; confirm whether auto-triggered login works → "client-flow" reference. - UNVERIFIED: passing
allowCredentials: []vs omitting it togenerateAuthenticationOptionsbehaves identically in all target browsers. By spec both mean "discoverable credentials" (allowCredentialsdefaults to[]when absent;@simplewebauthn/server@13.3.3omits the key when the option is undefined), and Chromium's virtual authenticator accepts the omitted form (verified 2026-08-30 in matatabetai e2e) — real iOS / Android not yet exercised → "Login" section.
Verified 2026-08-30 in matatabetai (first production user of this skill)
@simplewebauthn/server@13.3.3verifyAuthenticationResponsethrowsResponse counter value N was lower than expected Monly when(counter > 0 || credential.counter > 0) && counter <= credential.counter(esm/authentication/verifyAuthenticationResponse.js). The explicitstored !== 0 && new <= storedguard is therefore redundant; matatabetai keeps the rule as a pure functionisCounterRegressionwith a unit test instead of duplicating it in the route.@simplewebauthn/browser@13.3.0WebAuthnErrorCode=ERROR_CEREMONY_ABORTED | ERROR_INVALID_DOMAIN | ERROR_INVALID_RP_ID | ERROR_INVALID_USER_ID_LENGTH | ERROR_MALFORMED_PUBKEYCREDPARAMS | ERROR_AUTHENTICATOR_GENERAL_ERROR | ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT | ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT | ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED | ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG | ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE | ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY— every code used inreferences/client-flow.mdexists.hono/jwtverify()(hono 4.13) rejectsexp <= nowby itself (JwtTokenExpired), validatesnbf/iatwhen present, and accepts{ alg, aud }as the third argument — the challenge TTL can rely on it (unit test in matatabetaiworker/middleware/challenge-cookie.test.ts).- Raw
D1Database.batch()on workerd returns per-statementD1Resultwithmeta.changes; a 0-rowUPDATE … WHERE consumed_at IS NULLyieldssuccess: true, changes: 0(the invite race check works as written). Drizzle'sdb.batch()was not needed — the registration batches usec.env.DBdirectly. - WebAuthn at
http://127.0.0.1:<port>is not needed: bindwrangler dev --ip 127.0.0.1(sandbox requirement) but open the page athttp://localhost:<port>withRP_ID=localhostandORIGIN=http://localhost:<port>; Chromium resolveslocalhostto the loopback and the CDP virtual authenticator registers / asserts fine. Written intocloudflare-workers-e2e-playwright. Secure/__Host-cookies overhttp://127.0.0.1were never exercised: the ORIGIN-scheme derivation means http origins get bare, non-Secure cookie names, so the question does not arise.deleteCookiemust carry the same attributes assetCookie. Hono's cookie serializer throws__Host- Cookie must have Secure attributesfor a__Host-name withoutsecure: true, and browsers drop a__Host-Set-Cookie withoutSecure— sodeleteCookie(c, name, { path: "/" })(the old template) breaks logout and challenge consumption on https. PasscookieBase(c)(which includessecure) todeleteCookietoo — the templates below and inreferences/session-and-challenge.mdnow do.wrangler types(wrangler 4.125) also emits the keys of a local.dev.varsinto the generatedEnvasstring. CI has no.dev.vars, so type the secrets yourself and intersect:type Bindings = Env & { SESSION_SECRET: string; INITIAL_REGISTRATION_TOKEN?: string; DEV_BYPASS_USER_ID?: string }— consistent in both environments.@cloudflare/vite-plugin@1.53copies.dev.varsintodist/<worker>/.dev.varsat build time, sowrangler dev --config dist/<worker>/wrangler.jsonpicks up the devORIGIN— pass--var ORIGIN:… --var RP_ID:… --var SESSION_SECRET:… --var INITIAL_REGISTRATION_TOKEN:…for the e2e server to make the values explicit.- Client side,
@simplewebauthn/browseris optional: matatabetai calls the browser JSON API directly (PublicKeyCredential.parseCreationOptionsFromJSON/parseRequestOptionsFromJSON+credential.toJSON(), Baseline since 2025-03;modern-web-guidancerecommends this over wrapper libraries) with the server'sPublicKeyCredentialCreationOptionsJSON/RegistrationResponseJSONimported as types from@simplewebauthn/server. The DOMException mapping is the same (NotAllowedError/InvalidStateError/SecurityError/NotSupportedError), and the Signal API (signalUnknownCredentialon login 404,signalAllAcceptedCredentialson the device list,signalCurrentUserDetailson rename) is feature-detected.
Verified 2026-09-01 in kokemusu (single-user variant, second production user)
- Single-user twist (no spaces, no invites):
register/verifyadds a credential to the existinguserrow when one exists instead of minting a user — the same token-gated door then serves all-passkeys-lost recovery and a futureRP_IDchange without orphaning that user's rows.register/begindecides theuid(existing row's id, else a fresh UUID) and signs it into the challenge state, so a retried verify can never create a second user. Two devices registered in production,INITIAL_REGISTRATION_TOKENdeleted afterwards. - The
__Host-deletion throw reached production unseen (kokemusu #15): vite dev, unit tests and e2e all run on http, where cookie names are bare and hono's prefix check never fires; the first https verify was a 500. Keep one unit test per cookie-clearing path with an httpsORIGIN(app.request(...)withORIGIN: "https://…"reproduces the throw in Node) and route every deletion through a singleclearCookie(c, name)that derivessecurefromORIGIN—deleteCookieis then called in exactly one place. wrangler secret putreads the value from stdin and prints nothing, and secrets cannot be read back —openssl rand -hex 32 | wrangler secret put INITIAL_REGISTRATION_TOKENstores a token nobody has ever seen (kokemusu 2026-09-01, had to mint again). Mint in two steps: print, then paste at the prompt.references/ops-and-recovery.mdis corrected; a pipe stays fine forSESSION_SECRET, which no human needs to see.- The wrangler 4 top-level
ratelimitsbinding onregister|login/begin|verifyis simulated locally (40-burst → 429s after 30, credential-free container, bothvite devandwrangler dev), so the fail-open middleware can be exercised in dev; e2e still strips it (playwright-e2e-in-docker-sandbox). - The e2e golden path (CDP virtual authenticator, in-container) covers register → reload → logout → login on this variant:
cloudflare-workers-e2e-playwright0.4.0.
Scope boundary — what this skill does NOT cover
- Spaces, memberships, the
invitestable, owner-only endpoints, the race-safe invite consumption batch, and the first-space bootstrap on initial registration →cloudflare-workers-space-membership-invite(this skill only defines theregister/*contract it plugs into) - OAuth (
arctic, Google/GitHub), the seeded-session e2e seam →cloudflare-workers-e2e-playwright+ mazuoboeru; PATs (Authorization: Bearerfor CLIs / agents / sibling apps, plugged in front of this session layer) →cloudflare-workers-pat-bearer-auth - Rate-limit binding config, WAF alternatives, bot-scan audit →
cloudflare-workers-bot-scan-defense secureHeaders/ CSP, SPA routing, deploy pipeline →cloudflare-workers-deploy-skeleton- Playwright virtual authenticator, e2e scope →
cloudflare-workers-e2e-playwright,playwright-e2e-in-docker-sandbox - Backups and point-in-time recovery of D1 →
cloudflare-d1-weekly-backup-via-pr; table-rebuild migrations →cloudflare-d1-drizzle-migration - Conditional UI (autofill passkeys), attestation verification, hardware-key-only policies, multi-RP-ID / related origins — not needed at family scale
References
- references/schema.md — Drizzle schema + generated SQL, storage choices, cleanup
- references/session-and-challenge.md —
types.ts, cookie helpers, challenge cookie,sessionMiddlewarewith sliding expiry and the dev-bypass twin guard - references/auth-routes.md — all
/api/auth/*handlers,csrfOriginCheck,authRateLimit,worker/index.tsmount order - references/client-flow.md —
@simplewebauthn/browsercalls, error mapping, React hook - references/ops-and-recovery.md —
.dev.vars.example, bootstrap token cycle, secret rotation, lost-passkey and forced-RP_ID-change runbooks
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

