Account module
Data layer and core auth: Drizzle/D1 users + sessions, WebCrypto PBKDF2 password hashing,
HttpOnly cookie sessions, signup/sign-in/sign-out/me, email plumbing (email verification +
password reset via single-use hashed D1 tokens, with a zero-credential dev mailbox standing
in for a real provider), self-serve account settings (change email, change password,
delete-my-account), and two-tier test auth for automated test suites — self-contained so the
module can be removed without touching unrelated code.
GDPR rationale for delete-my-account
Section titled “GDPR rationale for delete-my-account”DELETE /api/account (apps/worker/src/modules/auth/delete-account.ts) hard-deletes the
users row and everything that references it (sessions, auth_tokens) rather than
soft-deleting or anonymising it. This is a deliberate choice, not an oversight:
- The example site collects only an email address and a password hash — neither has any legitimate retention purpose (accounting, fraud, legal hold) once the account owner has asked for it to be removed, so there’s nothing a soft-delete would meaningfully preserve.
- GDPR’s right to erasure (Art. 17) expects deletion “without undue delay” once a data subject requests it; a soft-delete flag that keeps the row (and therefore the personal data) around indefinitely does not satisfy that on its own — it just defers the same deletion work to a future cleanup job that this template doesn’t build speculatively.
- A stamped project that layers on a genuine retention need (e.g. billing records that must be
kept for tax law) should NOT weaken this hard delete. Instead, the retained record should be
its own row, decoupled from
users(e.g. adeleted_account_billing_historytable keyed on a copy of the fields the retention law actually requires, written before the batch below runs), so the erasure request is still honoured for every field that has no independent legal basis to survive it. - Hard-deleting in a single atomic D1
batch()(rather than sequential best-effort deletes) is itself part of the compliance story: a partial failure that deletedsessionsbut left theusersrow behind would both undermine security (the row is still a valid login target once the failure clears) and leave personal data around that the request was supposed to remove.
Two-tier test-auth threat model (PT-13)
Section titled “Two-tier test-auth threat model (PT-13)”apps/worker/src/modules/auth/test-auth.ts exists so automated tests never have to drive the
real signup/password flow to get an authenticated session. It has two tiers because “safe for
a laptop/CI run” and “safe to point at a real prod deployment” are different threat models:
-
Tier 1 —
POST /api/test-auth/login, gated byTEST_AUTH_TOKEN. It logs in as any email (creating the user on the fly if it doesn’t exist yet), bypassing password verification entirely — a full auth bypass if it ever became reachable in prod, so it sits behind two gates that have to fail independently before it opens:- The token is never present in a deployment. It is not declared in
wrangler.toml’s[vars](which would ship it with everywrangler deploy) and is never awrangler secret. Locally it comes from.dev.vars(copy.dev.vars.example); under test it comes from the miniflarebindingsblock inapps/worker/vitest.config.ts. With it unset the route 404s before anything else is checked. isLocalRequest(), exactly like/api/dev/mailbox(PT-12) — a deployed Worker is only ever reached on a hostname Cloudflare routes to it, so this 404s in every real deployment even if someone did provision a token by mistake.
Neither gate is allowed to carry the whole weight: putting the token back into
[vars]is the specific regression that would collapse this to a single hostname check. - The token is never present in a deployment. It is not declared in
-
Tier 2 —
POST /api/test-auth/prod-login, gated byTEST_LOGIN_SECRET. Off by default: it 404s in every environment until a deployment deliberately provisions the secret (wrangler secret put TEST_LOGIN_SECRET), the same opt-in patternRESEND_API_KEYuses. Once provisioned, a caller never sends the secret itself over the wire — it signs a payload (email.expiresAt.host) with it locally (via the exportedcreateTestLoginToken()helper) and sends the signature. The route then bounds the blast radius of a leaked signature four ways: the signature is checked with a timing-safe HMAC comparison;expiresAtmust be no more than 5 minutes out, so a captured signature stops working shortly after it’s minted;hostmust match the request’s ownHostheader, so a signature minted for one deployment can’t be replayed against another even if they share a secret; and the target user must already exist — it can never create an account. It can, however, sign in as any existing user, so it should only be provisioned on deployments that actually run smoke tests. The residual risk is a leakedTEST_LOGIN_SECRETvalue itself, which is bounded the same way any other Worker secret is: store it only as awrangler secret, never inwrangler.toml, and rotate it periodically the same as any credential — there is no automatic expiry on the secret, only on tokens signed with it.
Rate limiting: tier 1 doesn’t rate-limit (it is unreachable in prod via either gate above, and
locally it isn’t an attacker-facing surface); the account-settings routes and the rest of
/api/auth/* are.
Touch-points
Section titled “Touch-points”apps/worker/wrangler.toml— the[[d1_databases]]binding (DB,migrations_dir = "migrations"), theAUTH_RATE_LIMITER[[unsafe.bindings]]rate-limiter binding, and the[build]command’sapply-d1-migrations-on-build.mjsstep.RESEND_API_KEYis set as a Worker secret (wrangler secret put RESEND_API_KEY), not a wrangler.toml var — unset in every local/dev/CI environment, which is exactly the signallib/email.ts’s factory and the/api/dev/mailboxroute use to stay in dev mode.apps/worker/src/env.ts— theDB: D1Databasefield onEnv(theRateLimitBindingsinterface it extends already declaresAUTH_RATE_LIMITER, seeapps/worker/src/lib/rate-limit.ts), plus optionalRESEND_API_KEYandEMAIL_FROM.apps/worker/src/index.ts— importsauth,account,devMailbox, andtestAuthfrom./modules/authand mounts them at/api/auth,/api/account,/api/dev, and/api/test-auth.apps/worker/src/db/— Drizzle schema (users,sessions,authTokens,devEmails) and thecreateDb()helper. Not undermodules/auth/because the schema/migrations are shared infrastructure other modules (billing, etc.) will also read from, per the design spec’sapps/worker/src/db/option.apps/worker/src/lib/crypto.ts—randomToken()/hashToken(), shared by session tokens (modules/auth/session.ts) and single-use auth tokens (modules/auth/tokens.ts).apps/worker/src/lib/request.ts—isLocalRequest(), the hostname check behind the session cookie’sSecureflag, the/api/dev/mailboxprod gate, and the tier-1 test-auth prod gate.apps/worker/src/lib/email.ts—EmailSenderinterface,ResendSender,DevMailboxSender, and thecreateEmailSender()factory (picks byRESEND_API_KEYpresence). Lives inlib/rather thanmodules/auth/since billing may reuse it for receipts later, per the ticket.apps/worker/migrations/— the generated D1 migrations forusers/sessionsand forauth_tokens/dev_emails.apps/worker/drizzle.config.ts— drizzle-kit config pointing at the schema/migrations above (pnpm --filter @template/worker run db:generateregenerates after a schema edit).apps/worker/scripts/apply-d1-migrations-on-build.mjs— runswrangler d1 migrations applyonly whenWORKERS_CI_BRANCH === 'main'(Cloudflare Workers Builds); no-ops locally and on PR builds.apps/worker/src/test/apply-migrations.ts+apps/worker/src/test/env.d.ts— test-harness wiring (applyD1Migrationssetup file,cloudflare:testProvidedEnvaugmentation) andapps/worker/vitest.config.ts—readD1Migrations()+TEST_MIGRATIONSbinding. All of this is generic D1-test-harness plumbing, not auth-specific, but nothing else uses D1 yet.apps/worker/src/lib/errors.ts— addedtooManyRequests()(429), used by the rate limit checks inmodules/auth/routes.ts.apps/worker/src/env.ts— alsoTEST_AUTH_TOKEN?: stringandTEST_LOGIN_SECRET?: string(secret) for two-tier test auth.apps/worker/.dev.vars.example— the local-devTEST_AUTH_TOKENvalue, andapps/worker/vitest.config.ts’s miniflarebindings— the CI/test one. Neither is a deployable var; see the tier-1 notes above for why.apps/worker/wrangler.toml— the comment recording that neitherTEST_AUTH_TOKENnorTEST_LOGIN_SECRETbelongs in[vars]. (TEST_LOGIN_SECRETis a real secret, provisioned per-deployment viawrangler secret put, same asRESEND_API_KEY.)apps/worker/src/modules/auth/— all auth route/session/password/token/dev-mailbox/ account-settings/test-auth module code.apps/web/src/App.tsx— importsSignInPage/SignUpPage/ResetRequestPage/ResetPage/DevMailboxPage/SettingsPagefrom./modules/accountand mounts/sign-in,/sign-up,/reset-password,/reset-password/:token,/dev/mailbox, and/settingsinsideLayout.apps/web/src/components/Layout.tsx— theUserMenucomponent (callsuseUser(); renders sign-in/sign-up links, or the signed-in email + a Settings link + sign-out button) and<VerifyPromptBanner />, mounted below the header.apps/web/src/modules/account/— all module code (api client,useUser()hook, SignUp/SignIn/ResetRequest/Reset/DevMailbox/Settings pages,VerifyPromptBanner).
Removal steps
Section titled “Removal steps”- Delete
apps/web/src/modules/account/. - Remove account’s entries from
apps/web/src/modules.config.tsx: the account page routes (sign-in, sign-up, reset-password ×2, dev/mailbox, settings) frommoduleRoutes, andUserMenu/VerifyPromptBannerfromheaderSlot/bannerSlot— plus the now-unused import at the top of the file. - In
apps/worker/src/index.ts, remove theimport { auth, account, devMailbox, testAuth } from './modules/auth'line and theapp.route('/api/auth', auth)/app.route('/api/account', account)/app.route('/api/dev', devMailbox)/app.route('/api/test-auth', testAuth)calls. - Delete
apps/worker/src/modules/auth/. - In
apps/worker/src/lib/rate-limit.ts, removeAUTH_RATE_LIMITERfromRateLimitBindings(if no other module uses it). Deleteapps/worker/src/lib/email.ts(and its test) unless billing has started reusingEmailSenderfor receipts. - In
apps/worker/wrangler.toml, remove the[[unsafe.bindings]]block forAUTH_RATE_LIMITER, theTEST_AUTH_TOKENnote (plusapps/worker/.dev.vars.exampleand theTEST_AUTH_TOKENentry invitest.config.ts’s bindings), theRESEND_API_KEYsecret, any deployedTEST_LOGIN_SECRETsecret, and — if no other module reads D1 — the[[d1_databases]]block and theapply-d1-migrations-on-build.mjsstep from[build].command. - Remove
RESEND_API_KEY,EMAIL_FROM,TEST_AUTH_TOKEN, andTEST_LOGIN_SECRETfromapps/worker/src/env.ts. - If no other module uses D1: delete
apps/worker/src/db/,apps/worker/migrations/,apps/worker/drizzle.config.ts,apps/worker/src/test/apply-migrations.ts,apps/worker/src/test/env.d.ts, revertvitest.config.tsto a plaindefineWorkersConfig({...}), removeDBfromapps/worker/src/env.ts, and removedrizzle-orm/drizzle-kitfromapps/worker/package.json. Otherwise leave that infrastructure in place for whatever module still needs D1. - Delete this page (
apps/docs/src/content/docs/modules/account.md) and the links to it from the modules index and the new-project checklist — a broken internal link fails the docs build. - Run
pnpm checkto confirm the rest of the suite is still green with the module gone.
Known gaps / deliberate deviations
Section titled “Known gaps / deliberate deviations”- PBKDF2 iteration count (100,000, not OWASP’s 600,000+): benchmarked on this machine,
PBKDF2-SHA256 costs roughly 0.17ms per 1,000 iterations, so 600,000 iterations is ~100ms of
CPU time — comfortably over the Workers “bundled” usage model’s 50ms/request CPU limit (and
the free plan’s 10ms limit can’t fit any iteration count worth using). 100,000 iterations
costs ~15-20ms measured on this machine, leaving headroom under the bundled limit while
still being an order of magnitude above legacy (~10k) defaults. Projects on the “unbound”
usage model should raise
ITERATIONSinapps/worker/src/modules/auth/password.ts. - Secure cookie flag is conditional on the request’s own protocol, not hard-coded
true—wrangler devserves plain HTTP locally by default, and a hard-codedSecureflag would silently break the local signup/login flow (browsers dropSecurecookies set over HTTP). Real deployments (workers.dev / a custom domain) are always HTTPS, so the flag is still effectively always on in production. apply-d1-migrations-on-build.mjsgates onWORKERS_CI_BRANCH, the Cloudflare Workers Builds env var for the branch being built. This is not exercised by the test suite (it depends on the Workers Builds runtime) — confirm the exact env var name against the Cloudflare dashboard docs for your account before relying on it in production.- No dev-time proxy from
apps/web’s Vite dev server toapps/worker’swrangler dev.apps/web/src/modules/account/api.tscalls relative/api/auth/*paths, which resolve correctly when the worker serves both the API and the built SPA assets together (production, andwrangler devonceapps/webis built) — but runningpnpm --filter web devon its own Vite dev server has no/api/*to hit. This is a pre-existing gap in the chassis (not introduced here); aserver.proxyentry inapps/web/vite.config.tspointing/apiat the worker’s dev port would close it, but is out of scope for this module. - Email change updates
users.emaildirectly rather than tracking a separate “pending email” column. The new address is live (and unverified) the moment the request succeeds, rather than staying on the old, verified address until the new one is confirmed. This keeps the schema unchanged and reuses the existing single-address verify-token flow as-is, at the cost of a self-inflicted foot-gun: a typo’d new address immediately becomes the account’s sign-in email, with no automatic path back to the old one short of anotherPATCH /api/account/emailcall (which itself needs the current password, not the ability to read the old email, so it’s still recoverable by the account owner, just not undoable in one step). ApendingEmailcolumn that only promotes toemailon verification would close this gap but is a schema change beyond what this ticket’s touch-points call for. deleteAccount()’s hook point is a plain exported function, not a registry/plugin system. The ticket calls it a “hook point” for billing to register rows into; the simplest thing that satisfies that without speculative abstraction is a documented function indelete-account.tsthat a later module edits directly to add its owndb.delete(...)statement to the batch. A dynamic registry (registerDeletionHook(fn)) was considered and rejected — nothing else needs to plug into deletion, and it would just be indirection between the one caller (billing, when it lands) and the one implementation.- Dev/prod signal for email sending is
RESEND_API_KEYpresence, read directly offEnvrather than a separate flag — matches the ticket’s suggested signal and needs no new wrangler config.RESEND_API_KEYis unset in every local/CI environment by construction (it’s a secret, never a wrangler.toml var), soDevMailboxSenderand/api/dev/mailboxare live by default and only go away once a real deployment sets the secret. /api/dev/mailboxgates on hostname as well asRESEND_API_KEY— the secret alone is not a safe prod signal, because a deployment that never set it would fall back toDevMailboxSenderand then serve the whole outbox (live reset links included) to the internet.isLocalRequest()is the gate that actually holds: a deployed Worker is only reached on a hostname Cloudflare routes to it, solocalhostis unreachable in production./api/dev/mailboxlives under/api/*, not at a bare/dev/mailboxworker route —wrangler.toml’srun_worker_firstonly covers/api/*; a bare/dev/*path would need its own entry there to ever reach the Worker (rather than the SPA shell) in a real deployment. Keeping it under/api/*needs no wrangler.toml change and self-404s correctly both in the workerd test suite and in a real prod deployment. The web-side page that renders it is mounted at the/dev/mailboxclient-side route (apps/web/src/App.tsx) and fetches/api/dev/mailboxfor data.- Password reset invalidates all of a user’s existing sessions (
deleteSessionsForUserinmodules/auth/session.ts), not just the one used to request the reset — not explicitly required by the ticket, but leaving other sessions alive after a reset would undermine the point of resetting (a session hijacked before the reset would otherwise survive it). - Auth tokens are single-use via a select-then-update, not a single atomic statement — good
enough for D1’s effectively-single-writer model at this scale; a true CAS (
UPDATE ... WHERE used_at IS NULL RETURNING ...) would close a theoretical race between two concurrent consumes of the same token, but is left as a future hardening step rather than added speculatively here.