Identity & credentials
A password system asks who are you? and then checks a secret you hand over. Passkey login asks nothing at all. There is no username field, no email, no identifier of any kind — the user clicks one button, the authenticator signs a challenge, and the server derives who they are from the signature itself.
Identity is proven, never claimed. That single change eliminates whole attack classes rather than mitigating them: nothing is asserted, so nothing can be forged; no secret crosses the wire, so nothing can be intercepted or replayed; no account is ever named, so none can be enumerated, targeted, or credential-stuffed.
The schema is downstream of that guarantee — every table below exists to keep it true. This page covers what is stored, how it relates, and where the hardening comes from; Passkeys (WebAuthn) covers the ceremony and the verification checks themselves.
What you can change, and what you can’t
Section titled “What you can change, and what you can’t”Phaze Auth reads exactly two things. Rename a column in either and the session resolver stops working. It never looks at anything else on this page — those tables are yours to design.
-- Phaze Auth READS this. Don't rename these columns.CREATE TABLE users ( id TEXT PRIMARY KEY, -- opaque, authenticator-bound roles TEXT NOT NULL DEFAULT '' -- comma-separated; split on read);The other one is the KV session record — session:<token> → { userId, expires }, the SessionRecord / SessionRec wire both runtimes serialise. Phaze Auth covers the resolver that reads them.
Phaze Auth doesn’t care where credentials live. It verifies a passkey (phaze::auth::webauthn) and mints a session (mintSession) — nothing more. Where you put the credential, and how login finds it, is up to you. Here is a shape that works:
-- Phaze Auth never reads this. Design it however you like.CREATE TABLE credentials ( cred_id TEXT PRIMARY KEY, -- credentialId, base64url user_id TEXT NOT NULL, public_key TEXT NOT NULL, -- COSE_Key bytes, base64url sign_count INTEGER NOT NULL DEFAULT 0, -- clone detection webauthn_transports TEXT NOT NULL DEFAULT '[]', -- the wire field is `transports` aaguid TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);CREATE INDEX idx_credentials_user_id ON credentials(user_id);A second-factor table follows the same cascade shape. So does a device table, if an app has native clients — a CLI or desktop app that signs in and then holds a long-lived registration:
-- Phaze Auth never reads this either. One shape that works:CREATE TABLE devices ( device_id TEXT PRIMARY KEY, -- minted SERVER-side, inside an authenticated call user_id TEXT NOT NULL, public_key TEXT NOT NULL, -- the install's key, for sealing its channel fingerprint TEXT NOT NULL DEFAULT '', -- phaze::auth::fingerprint::machine(), HKDF-derived fingerprint_type TEXT NOT NULL DEFAULT '', -- which axes answered: both | uuid | gpu | none blocked_attempts INTEGER NOT NULL DEFAULT 0, -- +1 each time THIS row refuses someone last_seen_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);The last two are there for the same reason early_access carries them, and they are the half most
often left out: a gate that only writes when it acts cannot be audited. fingerprint_type records
which signal answered, so an axis going dark is visible instead of looking like healthy data.
blocked_attempts counts on the row that did the refusing, never the one refused — so a number
that keeps climbing means that fingerprint is matching strangers, and every one of them was a real
person turned away silently.
Two more things are worth copying rather than the columns. The primary key is minted server-side, inside a call that has already authenticated — a client-chosen id would let one machine overwrite another’s public key by naming it. And a device id a client sends back on a later request is a claim, honoured only after checking the row already belongs to the account that just authenticated; anything else falls through to a fresh row, so a stale or forged value costs a new record rather than someone’s key.
Those two columns look alike and answer opposite questions, which is worth being explicit about because the difference decides what each can be used for:
device_id | fingerprint | |
|---|---|---|
| produced by | the server, random, inside an authenticated call | the machine, measured from its own hardware |
| answers | which row is mine | which machine is this |
| the client keeps | the id, in its config | nothing — it is re-derived every run |
| lost when | the client’s config is cleared | never |
| duplicates | one row each, by construction | many rows can share one |
device_id is a name the server gave the client, and it exists so a returning machine updates
its row instead of creating a second. Lose the file that holds it and the name is gone — the next
sign-in has nothing to claim and registers the same machine again.
fingerprint is a measurement of the machine. It names no record, so several rows legitimately
carry the same one — and that is exactly what makes the duplicates above recognisable as one machine
after the fact. A client can throw away its name; it cannot throw away its hardware.
It is also why the fingerprint is recorded at enrolment rather than earlier: the row needs a
user_id, so until the client has authenticated there is nothing to write it onto — and the
question “does another account already hold this machine?” cannot be asked before knowing which
account is asking.
The fingerprint column is what makes “is this the same machine?” answerable at all — see
Fingerprint. What an
app does with a collision is its own policy, and the two reasonable answers are far apart: one
account per machine is a real rule for a licensed product, and plainly wrong for a developer tool where
one laptop legitimately holds a work account and a personal one.
The relationships
Section titled “The relationships” ┌─────────── KV (ephemeral) ────────────┐ ┌──── D1 (durable) ────┐
cookie ──► session:<token> users { userId, expires } ─── userId ──► ├── id TEXT PK TTL 7d └── roles TEXT ▲ ▲ │ │ ON DELETE CASCADE credentials ──────────┘ │ ├── cred_id TEXT PK │ ├── user_id ─────────────┘ ├── public_key │ └── sign_count │ │ totp ─────────────────────┘ └── user_id TEXT PK
your domain tables (waitlist, billing, …) ──── deliberately NOT relatedThree things this picture encodes:
- Session state is not in the database. It lives in KV with its own TTL, so the durable identity and the ephemeral session have separate lifetimes and separate stores.
usersis the hub. Credentials and second factors hang off it; nothing hangs off them.- Your domain tables stay out of it. A waitlist row is a prospect; a
usersrow is an authenticated identity. They answer different questions and have different lifecycles, so no foreign key should join them. The absence of that relationship is the design, not an omission.
What the cascade actually deletes
Section titled “What the cascade actually deletes”ON DELETE CASCADE fires when a users row is deleted — that is, when the account is deleted. It removes that user’s credentials and totp rows along with it. It does not fire on logout, on session expiry, or on deleting a credential.
So there is exactly one way to trigger it, and it means “this account is gone”, not “this person signed out”.
Don’t confuse it with the two session-level mechanisms, which are unrelated and operate at a different scope:
| What triggers it | What it affects | |
|---|---|---|
| Logout / revoke | delete the KV session:<token> | one session — the account and its passkeys are untouched |
| Live kill switch | broadcast('session:<token>', 'null') | already-open connections — a notification, not the revocation itself |
| Cascade | DELETE FROM users WHERE id = … | the whole account — credentials and second factors go too |
The kill switch exists because revoking is invisible to a held connection: deleting the KV record makes the next request fail, but a page holding an open stream makes no requests, so it would sit there believing it is still signed in.
The user id is opaque and carries nothing
Section titled “The user id is opaque and carries nothing”Registration resolves the id server-side — attaching to the signed-in identity when there is one, minting a fresh one only when there isn’t:
const userId = session?.user.id ?? b64url(crypto.getRandomValues(new Uint8Array(16))) // 128-bit, 22 charsThe client never supplies it. The WebAuthn user: { id, name, displayName } object is handed to the authenticator for its own picker UI and is never persisted — there is no name column, and nothing about it round-trips on login. So the id is opaque in both directions: it carries no name, and no name can set it.
Roles: denormalized so authorization is free
Section titled “Roles: denormalized so authorization is free”const user = await db.prepare('SELECT id, roles FROM users WHERE id = ?1').bind(rec.userId).first()const roles = user.roles.split(',').filter(Boolean)No roles table, no user_roles join. The roles ride in the same row that resolves the session, so authorization costs zero extra queries — one KV get and one D1 row, whether or not any role is checked.
That matters because the resolve sits on the per-request hot path, not the per-login one: it runs for the pre-stream route guard (the isomorphic lifecycle’s, before any shell flushes), for every action declaring use: [session], and for every authorization gate.
.filter(Boolean) is load-bearing — it turns the DEFAULT '' into [] rather than [''], so an unroled user has a clean empty array with no sentinel to guard against.
Roles are free-form strings with no enum or constraint; a name like 'admin' is a convention your checks agree on, not a schema fact. Two shapes consume them:
| Surface | Check |
|---|---|
Route guard (restricted) | intersection — roles.some((r) => have.includes(r)) |
Action gate (require:) | a throwing gate, e.g. roles.includes('admin') |
Login offers credentials, it does not ask who you are
Section titled “Login offers credentials, it does not ask who you are”At login-start there is no identity to filter on — no username was submitted, and the session cookie is exactly what the user is trying to obtain. So the server cannot look up “this user’s credentials”. It returns a list of candidate handles instead.
You write this query, not Phaze Auth. One shape that works is to offer the newest handles, capped:
// your login-start handler — Phaze Auth never runs thisconst creds = await db.prepare( 'SELECT cred_id, webauthn_transports FROM credentials ORDER BY created_at DESC LIMIT 20').all()// column `webauthn_transports` → wire field `transports` (the WebAuthn IDL name)const allowCredentials = creds.results.map((c) => ({ type: 'public-key', id: c.cred_id, transports: JSON.parse(c.webauthn_transports || '[]'),}))Note what is not selected: user_id. It sits in the same row and is deliberately left out, so what reaches an anonymous caller is a set of opaque handles with no identity attached — you cannot tell whose they are, or which belong to the same person. The join happens only after the signature verifies:
// login-finish — an indexed primary-key hit, for the one credential that was actually signedawait db.prepare('SELECT user_id, public_key, sign_count FROM credentials WHERE cred_id = ?1')That ordering — offer opaque handles → prove possession → then resolve identity — is the property to preserve. Identity is derived from the proof, never asserted by the client.
Why the credentials are non-discoverable
Section titled “Why the credentials are non-discoverable”The creation options set no residentKey, so credentials are non-discoverable: the authenticator stores nothing, and the private key is wrapped into the credential ID itself. Handing that ID back is what lets the device unwrap it — which is precisely why the allowlist has to exist.
Two things that buys:
- No authenticator storage. Discoverable (resident) credentials occupy a slot on the device, and hardware keys hold only a few dozen — registration fails once full. Non-discoverable credentials occupy none, so a device can hold unlimited ones.
- No account enumeration. A classic
POST /login {email}leaks whether an account exists through timing, error text, or response shape. Here there is nothing to submit, so there is nothing to probe.
transports — the other half of the allowlist
Section titled “transports — the other half of the allowlist”Because nothing is discoverable, the server has to supply all the routing information. allowCredentials says which credential; the stored transports say how to reach it. Drop either half and the browser cannot complete the lookup.
Two names, one value. transports is the WebAuthn IDL field — it is what getTransports() returns and what rides the descriptor, so it keeps that name everywhere the spec or a library owns it. The column is webauthn_transports, because a bare transports collides with Phaze’s own vocabulary — the transport: fence knob and the transport.* bindings, neither of which has anything to do with it. The INSERT is the one line carrying both names.
The values are a fixed enum from the WebAuthn spec — AuthenticatorTransport:
type AuthenticatorTransport = "ble" | "hybrid" | "internal" | "nfc" | "usb"| Value | Where the credential is reached |
|---|---|
internal | built into this device — Touch ID, Face ID, Windows Hello |
hybrid | a different device — phone over QR code + Bluetooth |
usb · nfc · ble | a removable security key, over that wire |
They ride into PublicKeyCredentialDescriptor, the same object that carries the credential ID:
interface PublicKeyCredentialDescriptor { id: BufferSource transports?: AuthenticatorTransport[] // optional in the IDL — see the caution below type: PublicKeyCredentialType}The round trip is deliberately lossless: registration stores whatever getTransports() returned (JSON.stringify(input.transports ?? [])), and login replays it unchanged (JSON.parse(c.webauthn_transports || '[]')). No filtering, no interpretation — the column exists to give the browser back exactly what it told you.
Both ends of that conversation are Phaze Auth’s; the middle is yours. createPasskey captures the array from the authenticator and getPasskey hands it back to navigator.credentials.get() — you never call getTransports() yourself. Where the array is parked between those two calls is entirely your decision; Phaze Auth never reads your column, it only needs the same array back on the next get(). (The Rust crate never sees it at all — it is routing information, not something to verify.)
aaguid
Section titled “aaguid”A 16-byte ID for the authenticator’s provider or model. It originates in the authenticator’s own registration data and reaches you through the browser — a YubiKey reports one value, Apple Passwords another, Chrome’s test authenticator another.
We save it when someone registers a passkey.
We use it to tell rows apart. In the credentials table every row looks the same — random ID, same columns, no name. The aaguid is the only field that says where each credential came from.
It identifies the provider or model, not the device and not the person. Everyone using Apple Passwords reports the same value; so does everyone using a YubiKey 5. One person registering five times on the same phone produces five credentials with five different cred_ids — and one shared aaguid.
So it groups rows by what made them, never by whose they are. That is what makes it useful for separating a batch of test credentials from real ones, and useless for identifying a user.
Treat it as optional. Registration requests attestation: 'none' — you are telling the browser you do not want proof of what the authenticator is, and under none the browser may omit the AAGUID or zero it out. Passkey providers commonly report one anyway, which is what lets account-settings screens say “saved to Apple Passwords”. But an empty or all-zero value is a normal outcome, so nothing should depend on the column being populated.
What attestation: 'none' buys
Section titled “What attestation: 'none' buys”Asking for no attestation removes a consent prompt and a network round-trip. Per MDN it avoids “additional user consent for round trips to the relying party server to relay identifying information, or round trips to an attestation certificate authority.” What you give up is knowing what hardware the user owns.
Passkey management survives that trade, because none of it depends on attestation:
| What a management screen needs | Where it comes from |
|---|---|
| which passkey is which | cred_id — always present |
| where it is stored, for the label | aaguid — reported by providers regardless |
| how to reach it at login | webauthn_transports |
| whether a copy is in use | sign_count |
So a user can see and manage their passkeys without ever being asked to prove what their device is.
Clone detection — what sign_count is for
Section titled “Clone detection — what sign_count is for”Every other check in the ceremony answers is this signature valid? The sign counter answers a question none of them can: is this the only copy of the key?
Authenticators increment a counter on each assertion and report it inside authenticatorData. The server keeps the last value it saw, and rejects any assertion whose counter has not moved forward:
// AFTER the signature has already verifiedif auth.sign_count != 0 && stored_sign_count != 0 && auth.sign_count <= stored_sign_count { return Err(Unauthorized("sign-count regression … possible cloned authenticator"))}Why nothing else catches this. If someone extracts a private key from a device, the clone produces a genuinely valid signature — it is the real key. Origin, challenge, RP-ID hash, user-presence, and the signature check all pass, because none of them are wrong. The only thing the attacker cannot control is history: two copies increment independently, so one eventually presents a counter the server has already seen. The tell is ordering, not validity — which is exactly why this is a layer rather than a duplicate of the signature check, and why it runs after it (a counter check on an unverified assertion would mean nothing).
Detecting it is refusing it. The check sits inside the auth decision, not beside it — a regression returns an error from verify_assertion, the handler throws UNAUTHORIZED, and the lines that persist the counter and mint the session are never reached. The clone gets no session, and there is nothing to revoke afterwards because nothing was granted. Not an alert for someone to act on later; a closed door.
What it genuinely cannot do is stop the key being copied — that’s physical extraction, beyond the server’s reach. It stops the copy being used.
The cost is close to nothing: one integer stored, one comparison, one UPDATE per login.
Why this beats the password-and-session model
Section titled “Why this beats the password-and-session model”Security
Section titled “Security”| Password + session table | This model | |
|---|---|---|
| Secret at rest | a password hash — offline-crackable once dumped | a public key — dumping the table yields nothing usable |
| Server trust | the server receives the plaintext password on every login | the private key never leaves the authenticator |
| Phishing | a user can type the password into a convincing fake | the signature is origin-bound; a wrong origin fails the rpIdHash/origin check before any crypto matters |
| Replay | a captured password works until rotated | each assertion signs a single-use challenge; replay fails |
| Enumeration | POST /login {email} reveals whether an account exists | no identifier is ever submitted — nothing to probe |
| Credential stuffing | reused passwords transfer breaches between sites | keys are per-site by construction; nothing is reusable |
| Revocation | needs a blacklist, or short TTLs and a refresh dance | DELETE the row — the next request fails, and the cascade retracts the credentials from the offer |
The through-line: a password system stores something that is valuable to steal and asks the user to hand it over repeatedly. This model stores a public key and never transmits a secret at all, so the entire class of “the database leaked” and “the user was tricked into typing it” has nothing to act on.
Performance
Section titled “Performance”| Password + session table | This model | |
|---|---|---|
| Verify cost | a deliberately slow KDF — bcrypt/argon2 are tuned to take real CPU time on purpose | ECDSA P-256 verify, sub-millisecond — security comes from key strength, not work factor |
| DoS shape | an attacker forces expensive hashes with junk logins; the work factor is the weapon | verification is cheap, so there is no asymmetric cost to weaponize |
| Queries per request | session lookup + user lookup + a roles join | 1 KV get + 1 D1 row, roles included |
| Authorization | usually a user_roles join or a second query | zero extra queries — the roles are in the row already read |
| Auth-path index | varies | primary-key hit: WHERE cred_id = ?1 |
The password model’s headline cost is not incidental — a KDF is designed to be slow, and that cost lands on every login and on every attacker-triggered one. Signature verification is fast for the opposite reason: its strength comes from the key, so there is nothing to slow down. Combined with roles riding the session row, an authenticated, authorized request needs no more database work than an anonymous one.
Why deletion is enough
Section titled “Why deletion is enough”The cascade does more than tidy up child rows — it is the revocation path, because of what those rows were doing:
- The credentials were being advertised. They came back in
allowCredentialson every login attempt. Deleting them retracts the handles from the offer, so there is no separate revocation list to maintain and no stale handle left being announced to anonymous callers. - The sessions were pointing at a row that no longer exists. The resolver returns
nullwhen theuserslookup misses, so every session for that user dies on its next request — no sweep of the KV namespace, no waiting for a TTL.
Nothing is cached anywhere along that path, which is what makes it immediate. That is the server-revocable property a stateless token cookie cannot offer: a signed JWT stays valid until it expires, no matter what the database says.
See also
Section titled “See also”- Passkeys (WebAuthn) — the ceremony, verification checks, and RP-ID scoping
- Phaze Auth — the resolver, app auth config, and browser helpers