Design notes: Auth flow
Status: Living design document, written while the flow is being built and tested. Identity & credentials describes the data model that is settled; this note covers the process — the order the steps happen in, and why that order is forced.
Every claim here is traced against running code or observed against a live system. Where something is undecided it says so rather than describing an intention as if it shipped.
The problem this note exists to solve
Section titled “The problem this note exists to solve”Passkey login is usernameless: the user clicks one button and the server derives who they are from the signature. That works beautifully after the first credential exists. It says nothing about how the first one gets there.
Registration binds a credential to an identity. So registration needs an identity to bind to — which means something other than a passkey has to establish it first. That is the bootstrap, and it is the whole subject of this page.
The ordering, told by scenario
Section titled “The ordering, told by scenario”The order is easier to see per population than in the abstract, because what differs between them is only how they were admitted — everything after that is one path.
Scenario A — Early Access signup
Section titled “Scenario A — Early Access signup”The person puts themselves on the approval list and leaves the same visit with a passkey — one flow, not two.
| What happens | What exists afterwards | |
|---|---|---|
| 0 | Signs up on the approval-list page (EmailCode.request with intent: 'signup'). A code is written to KV otp:<email>; EmailCode.verify proves the inbox | D1 early_access.email_verified = 1 and a D1 users row — id, roles = '', email — the account exists now. Plus a single-use registration token: KV reg:<token> → { user_id } (10 min) and its cookie. No session: — verify signs no one in (b3ddd56 removed a session mint here that logged in everyone who filled the form). |
| 1 | Registers a passkey in the same visit, from that token (bootstrapStart / bootstrapFinish, both gated on use: [registrationToken]) | KV webauthn:chal:<challenge> → { user_id } (5 min, consumed at finish), then D1 credentials — a row whose user_id is the id the token named. Or skips (bootstrapSkip): the token is spent, a 2-day session is minted, and a workflow arms the reminder email (seconds(30) in dev, days(1) in prod) that links them back to finish later. |
| 2 | Every time after: passkey login. From that session they can also approve a second device by QR | nothing new in D1; approving a terminal adds a second independent session:<token> |
The account and the credential are both created inside the one signup visit; the registration token is what carries the just-proved identity from verify to the passkey ceremony without minting a session. A returning person who has no passkey — or who skipped it — signs in on a different lane: EmailCode.request with intent: 'login' then EmailCode.login, which mints a session directly (see the two intents).
Everything durable is in one D1 database — early_access, users, credentials, totp — and everything ephemeral is in KV, split across two namespaces: the one-time codes (otp:) beside the approval list, and the session, challenge, and registration-token records (session:, webauthn:chal:, reg:) in the session namespace.
Roles are assigned out of band, at any point. Until one is, the account resolves normally and reaches nothing — which is what lets registration stay open.
Scenario B — manually approved
Section titled “Scenario B — manually approved”Step 0 is an early_access row inserted directly with verified = 1 — no signup page, no code. Steps 1–2 are identical.
Scenario C — admitted by domain
Section titled “Scenario C — admitted by domain”No per-person row at all: admission comes from the address’s domain appearing in the organization directory, approved once for everyone behind it. Steps 1–2 are identical.
What that shows
Section titled “What that shows”The OTP carries an explicit intent — signup or login — because the same code means two different things. Same mechanism, same otp: record, different terminal: a signup code, proved by EmailCode.verify, establishes the account and hands back a registration token; a login code, proved by EmailCode.login, mints a session for an account that already exists. Whether an address is verified is the address’s state, and it never says which terminal the code was headed for — so deriving the terminal from the state gets it backwards for exactly one population, silently. That is why the intent is declared, not inferred.
Verified is inbox proof, not approval. In scenario A the person sets it themselves by completing their own signup. What restricts access is the role assigned out of band, not the flag.
All three scenarios differ only at step 0. Steps 1 and 2 are one path, which is why a second admission source is a second lookup rather than a second flow.
Why the order can’t be rearranged
Section titled “Why the order can’t be rearranged”Underneath the scenarios is a structural rule — each step needs something the step above it produces:
- A second factor cannot bootstrap. Enrolment writes a secret against
session.user.id. No session, no user id, nothing to enrol against. The same holds for any factor keyed to an existing identity. - A passkey cannot bootstrap. Registration binds a credential to a user id, so it needs an identity to bind to. Without one the ceremony has to invent it, and an invented identity is the thing the binding exists to prevent. Note this is about producing a correct result, not about whether the endpoint can be called — see Open questions.
- An emailed one-time code can. It requires no prior identity: the proof is control of an inbox, and the server needs to know nothing about the caller to check it.
That last property is what makes the emailed code the bootstrap and nothing else a candidate.
What has to be true before each kind of user can be authed
Section titled “What has to be true before each kind of user can be authed”Different populations are admitted differently, but they converge immediately — the entry lanes differ, the rest of the path is one path.
ENTRY LANES — admission differs per population ═══════════════════════════════════════════════════════════════════════
individually approved organization member returning person ───────────────────── ─────────────────── ──────────────── the ADDRESS is on the DOMAIN is in already holds a the approval list the org directory credential for (one row per person) (one row per org — this origin │ 50 people, no rows) │ │ │ │ └────────────────┬────────────────┘ │ ▼ │ ═══════════════════════════════════════════════════════════ ─────┼───── BOOTSTRAP — the only step that runs from nothing │ │ ┌──────────────────────────────┐ │ 1 │ OTP proved │ │ │ control of the inbox │ │ └──────────────┬───────────────┘ │ ▼ │ ┌──────────────────────────────┐ │ 2 │ SESSION │ │ │ the account now EXISTS and │ │ │ the address is bound to it │ │ └──────────────┬───────────────┘ │ │ │ ═══════════════════════════════╪═════════════════════════════════ │ FROM INSIDE A SESSION — everything below needs step 2 first │ │ │ │ ┌──────────────────┼───────────────────┐ │ ▼ ▼ ▼ │ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ 3 │ REGISTER a │ │ ENROL a second │ │ APPROVE a │ │ │ passkey │ │ factor (TOTP) │ │ second device │ │ │ │ │ │ │ (scan the QR) │ │ │ binds │ │ writes against │ │ mints a SECOND │ │ │ credential ──► │ │ session.user.id │ │ session off │ │ │ the identity │ │ │ │ this one │ │ └───────┬────────┘ └──────────────────┘ └────────────────┘ │ │ │ └──────────────────────────┬─────────────────────────────────┘ ▼ ═══════════════════════════════════════════════════════════════════════ ┌──────────────────────────────┐ 4 │ PASSKEY LOGIN │ every time after. │ assertion verifies → │ no address typed, │ identity derived from it │ no list consulted └──────────────────────────────┘The diagram draws step 2 as a session because that is the general case — TOTP enrolment, second-device approval, and adding a passkey to an existing account all run from one. The signup lane is the one exception, and it is the whole reason the registration token exists: the first passkey is registered from that token (bootstrapStart, use: [registrationToken]), which stands in for a session for exactly that one step and grants nothing else. So read step 2 as “an established identity” — a session in every case but the first credential, where it is the narrow token.
Reading it by population:
- Individually approved — two things must be true before a passkey can exist: the address is on the list, and they proved the inbox. Everything after is shared.
- Organization member — identical, except admission was granted once for the whole domain. Nobody adds a row when the fiftieth person joins.
- Returning person — skips the entry lanes entirely. Their credential is the proof, so no address is submitted and no list is consulted. That is the usernameless property, and it is only available because step 3 happened once.
- Second device — never authenticates. It receives a session minted from one that already exists, so it inherits the person’s admission rather than establishing any of its own. It can never be the first session.
The diagram also shows why step 3 is a cliff rather than a step: everything in that band requires a session, and step 2 is the only thing that produces one from nothing.
What actually binds a credential to a person
Section titled “What actually binds a credential to a person”There is no column joining a credential to an email address. The relationship is transitive, through the user row:
credentials.user_id ──► users.id ──► users.emailcredentials holds no email; users holds no credential reference. The join is users.id, and it is established at exactly one moment — when the registration ceremony starts, not when it finishes.
The start step decides which identity the ceremony is for and parks that decision under the challenge:
const userId = session?.user.id ?? mintNewId()await kv.put(`webauthn:chal:${challenge}`, JSON.stringify({ user_id: userId }), { expirationTtl: minutes(5) })The finish step reads it back and writes the row. It decides nothing.
Two consequences follow from that placement, and both matter:
The binding is frozen before the biometric. By the time the user touches their authenticator, which account the credential will land on is already fixed and sitting in KV. Signing in during the ceremony does not change it.
The start step is the only place the decision can go wrong. If a session does not resolve there, the fallback branch mints a fresh identity, and nothing downstream repairs it: finish accepts no address, and an email sign-in looks accounts up by address, so the same person returning later gets a second row with their credential stranded on the first.
There are two starts, and only one carries that risk. The snippet above is Passkey.registerStart (use: [session]) — the path for adding a credential from an existing session (or a device claim). Its ?? mint a fresh id is exactly the fallback that keeps unauthenticated registration reachable (Open questions). The signup’s first passkey takes the other start, Passkey.bootstrapStart (use: [registrationToken]), where the identity is handed over rather than guessed — const userId = registrationToken.userId, and no token means it refuses rather than inventing anyone. So the anonymous-branch risk is real for registerStart and designed out of bootstrapStart; the difference is precisely what the registration token buys. bootstrapFinish is registerFinish minus the mint: [session] — same credential write, no sign-in.
Why the ceremony carries no identity to the device
Section titled “Why the ceremony carries no identity to the device”The WebAuthn user: { id, name, displayName } object is handed to the authenticator for its own credential picker and is never persisted server-side. The id is deliberately opaque — that property is load-bearing and should not change.
name and displayName are a different matter. They exist to let the operating system tell one saved passkey from another, and they never leave the user’s own device. A constant there means every credential on the origin appears identically in the picker: same label for a second device, same label for a different account. aaguid cannot disambiguate them, because it identifies the authenticator’s provider, not the credential — five registrations on one phone share one aaguid.
Where the ceremony runs from an established identity, that identity is available to label the credential. Where it runs from nothing, there is nothing to label it with — which is another way of seeing that the anonymous branch is the problem, not the labelling.
A second device doesn’t authenticate — it inherits
Section titled “A second device doesn’t authenticate — it inherits”The QR flow looks like a fourth way in and isn’t one. Nothing about it proves anything: the second screen is already signed in, and approving mints a new session for the same account off the one it already holds. The proving happened in the browser, by whichever path that browser used.
So a terminal login is some other login with a handover step — a browser one in every flow phaze ships, though an app that supplies the approving session another way (an emailed code carrying mint: [session], say) changes only where the session came from, never the handover. Three things follow:
- It can never be the first session. The approval step reads the caller’s session and refuses without one, and it mints against that session’s user id. There is nothing to derive an identity from if no session exists, so a CLI device flow cannot bootstrap an account any more than a passkey can.
- Everything on this page applies to it. The terminal doesn’t get its own admission rule, its own gate, or its own binding — it gets a token that resolves through the ordinary session record, and asks the ordinary questions with it.
- One identity, two independent sessions. Different tokens, different records, separate lifetimes. Revoking the one on the cookie leaves the other alive, which is correct — signing out of a browser should not sign out a terminal — but it means “sign out” and “sign out everywhere” are genuinely different operations, and only the first exists as a single delete.
The handover itself is the only CLI-specific machinery: a short code shown on one screen, a secret the terminal generated and never sent, and a held connection that receives the token when the second screen approves. All of that exists to move a token; none of it decides who anyone is.
What the account knows vs what a device knows
Section titled “What the account knows vs what a device knows”Two questions look alike and are not:
| answers | available | |
|---|---|---|
| device storage | what does this device hold | before identity, instantly, at no server cost |
| the account row | what does this account have | only once a session resolves — but then on every device |
The signed-out screen can only use the first, because there is no session yet to ask. That is not a limitation to design around; it is the only thing answerable at that moment, and it is what lets the page lead with the strongest credential the device actually holds instead of offering everything to everyone.
Everything after sign-in should use the second, because a device copy can be absent or stale in ways that matter. Enrol a second factor on a phone, sign in on a laptop, and the laptop’s copy simply does not exist — a surface gated on it would offer to enrol something already enrolled.
So the account row carries the capability flags, they ride the row the session resolver already reads (no extra query), and every successful sign-in refreshes the device copy from them. The copy is a projection with a named sync point, never a source of truth, and nothing writes to it as though it were one.
A native client knows a third thing — what the machine is
Section titled “A native client knows a third thing — what the machine is”The two columns above are what a browser can ask. A native client — a CLI, a desktop app — has a third, and it sits outside both: what this hardware is, answerable before any storage exists and without a session.
| answers | available | |
|---|---|---|
| device storage | what does this device hold | before identity, instantly |
| the account row | what does this account have | once a session resolves |
| the machine | which hardware is this | before identity, and it survives the storage being cleared |
That third row is the one a browser cannot have honestly. Its measurement identifies a GPU class, so it must be treated as evidence; a native process reads a per-unit identifier and hashes what the GPU actually computes, so a match is about a machine rather than a machine class (Fingerprint).
What it changes for this flow: device storage answers what did this install keep, and a cleared directory resets it — which is correct for a registration, because forgetting a machine is a supported act. The machine answer does not reset, which is what lets an app tell “a returning machine that was forgotten” apart from “a machine never seen”. Whether it should act on that is app policy, and it is worth deciding deliberately: the same signal that catches one person enrolling repeatedly also describes a developer with two legitimate accounts on one laptop.
How they ride the row
Section titled “How they ride the row”“No extra query” is a mechanism, not a hope. getSessionAndUser selects id, roles by default; a columns option widens that projection with app-owned fields, typed onto user:
const SESSION_COLUMNS = ['org_id', 'status', 'email', 'totp_enabled', 'passkey_enabled'] as const
getSessionAndUser(cookies.get(COOKIE), bindings(transport), { columns: SESSION_COLUMNS })The columns are close to free, and that is the whole argument. This row is fetched on every authenticated request either way, and SQLite reads the whole page regardless of the SELECT list — so the marginal cost of one more small scalar is only serialising it across the D1 boundary. That makes the bar size, not whether the value gates a decision: a flag the enrolment UI reads earns its place exactly as a tenant key does, because the alternative is a second query on the hot path for a row already in hand. A JSON blob or long text is the case that doesn’t qualify — it is paid on every authenticated request, so it belongs in its own query.
Declare the list once and share it across every call site. There are three here — the pre-stream route guard (isomorphic lifecycle, before any shell flushes), the use: [session] provider, and the require: gate — and they must agree, or the session a page guard sees has a different shape from the one a fence sees, which is the kind of divergence nothing fails loudly on.
One seam to know about. Widened values are typed string | null — the TEXT case, which is what an authorization scalar usually is. These particular flags are INTEGER 0/1, matching verified on early_access, so D1 returns a number where the type says string. Read sites coerce rather than compare against '1':
const totpOnAccount = c(Number(session.value()?.data?.user?.totp_enabled) === 1)That is a real edge, not a formality: '1' === 1 is false, so a string comparison here fails silently and the surface reads as “not enrolled” for an account that is.
This is a read-only replica, not a sync problem. Local-first database sync is hard almost entirely because the local replica accepts writes — that is where conflict resolution, merge semantics and ordering come from. Nothing here originates a fact locally: every value is derived, and the refresh is a full overwrite at a boundary. That keeps it a cache with a defined refresh point rather than a distributed-state problem, and the line is crossed the moment a device is allowed to originate something that must reconcile later.
use: [session] provides — the body enforces
Section titled “use: [session] provides — the body enforces”The session provider resolves the cookie and returns whatever it finds. It never rejects. A fence that declares it and then reads session.user.id without handling null does not fail closed; it fails to compile, which is different, and session?.user.id ?? somethingElse compiles fine.
That matters because every action endpoint is publicly reachable. Declaring the provider does not make a fence private, and no wire-level marking does either — a fence that reaches a backend over an internal binding still answers an ordinary request on the way in.
What the provider genuinely buys is that identity is server-derived: session.user.id comes from the cookie, so a fence using it structurally cannot act on another account — there is no field a caller could send to point it elsewhere. Enrolment, removal and step-up all get that property for free.
A fence that cannot derive identity — a login, by definition — has to accept an identifier as input, and at that moment it needs a different protection, because the input is under the caller’s control and may be something the system publishes elsewhere.
Removal has to be explicit, because nothing tells you
Section titled “Removal has to be explicit, because nothing tells you”A second factor can vanish without the server hearing about it: deleting the entry in an authenticator app is a purely local action, and the provisioning format carries a secret one way with no revocation URI and no status channel in either direction.
So removal is a second deliberate act by the account holder, and the same is true of the passkey side — a credential deleted from a keychain leaves its row, its capability flag, and its place in the login allowlist untouched. WebAuthn at least defines server→authenticator signalling for that direction; the time-based-code format defines nothing at all.
Two consequences worth designing for rather than discovering:
- Without an explicit removal path the account strands. The flag says enrolled, the enrolment UI hides because it believes that, and every code fails — with no way to clear either.
- Order the writes so a partial failure lands in the recoverable state. Clear the flag before deleting the secret: stopping between them leaves “not enrolled, secret orphaned”, which re-enrolment overwrites. The reverse leaves “claims enrolled, no secret”, which is stuck.
Admission — one gate, more than one population
Section titled “Admission — one gate, more than one population”The bootstrap step is also where admission is decided: is this address allowed in at all? That question has more than one answer source, and the design keeps them behind one gate rather than forking the flow:
| Population | How admission is established |
|---|---|
| Individually approved | the address appears on an approval list |
| Organization member | the address’s domain appears in an organization directory |
Approving a domain rather than a person is what lets an organization onboard fifty people without an administrator touching anything per-person. The individual list stays as the escape hatch for people on a personal address.
The gate is asked in two places — when a code is issued, and again when it is proved — and both must agree, or one population receives codes it can never redeem, and another is refused a code it is entitled to.
The refusal at issue time is deliberately silent: an unapproved address gets the same response an approved one does, so the endpoint cannot be used to discover who is on the list. The real refusal happens behind the code, where only someone holding the inbox learns anything.
Open questions
Section titled “Open questions”Two questions shaped this design. Both now have answers in the code — kept here because the reasoning is the point.
What does an emailed code give you — a session, or just proof the address is real? Both, and which one depends on why the code was asked for. A signup code (intent: 'signup') gives a one-time registration token (reg:<token>): it lets you add your first passkey and nothing else. A login code (intent: 'login', EmailCode.login) gives a full session — you are signed in. Same code mechanism, two outcomes, picked by the declared intent rather than guessed from the address’s state.
Can a passkey be registered without proving who you are? On the signup path, no: bootstrapStart needs that registration token, so the first credential cannot land on an invented identity. On the general add-a-passkey path (registerStart), yes — it still falls back to minting a fresh anonymous account. That is left open on purpose: a brand-new account carries no roles, so it reaches nothing until one is granted.
See also
Section titled “See also”- Identity & credentials — the tables, the cascade, and why login offers an allowlist
- Passkeys (WebAuthn) — the ceremony and its verification checks
- Phaze Auth — the session resolver and the mint helper