Phaze Auth
Phaze Auth is the reusable library behind a Phaze app’s sessions and passwordless login. It is not a framework you configure — it’s a small set of pure primitives you wire: a session resolver (cookie → KV → D1 → Session | null), the WebAuthn browser ceremony, and the WebAuthn / TOTP / session cores on the Rust runtime. The app’s auth.ts binds them; Phaze Transport carries them; the router’s restricted guard reads them.
It is isomorphic like the rest of Phaze: the session read is always server-side (KV/D1 are server-only bindings), whether it runs during SSR or behind a client-dispatched actions.Session.request(). There is no client-side session store, no hook, no middleware — just one resolver, shared.
The two halves
Section titled “The two halves”Phaze Auth ships as a TypeScript package and a Rust crate that share one session wire contract ({ userId, expires }):
@madenowhere/phaze-auth (npm — TS) phaze-auth (crates.io — Rust)├── . server primitives phaze::auth::session schema + token/b64url helpers│ getSessionAndUser · SessionRecord phaze::auth::webauthn passkey attestation + assertion verify│ sessionKey · cookieName · SessionBindings phaze::auth::totp RFC-6238 enroll / verify│ rateLimitBy · rateLimit (re-exported via `phaze`, features = ["auth"])├── /browser WebAuthn ceremony│ createPasskey · getPasskey · b64url helpers└── /fingerprint device identity (compiled lazy) fingerprint(token?) · the cached-token chain (a favicon route, path configurable)The two never share code — they share a contract: Rust’s SessionRec serialises to the same { userId, expires } JSON the TS SessionRecord interface describes, so a session minted by a Rust login/finish handler is read by the TS resolver without translation.
The session resolver
Section titled “The session resolver”The server entry (@madenowhere/phaze-auth) is built around one function — the canonical session read:
import { getSessionAndUser, type SessionRecord } from '@madenowhere/phaze-auth'
// token → KV `session:<token>` → D1 `users` → Session | null// null = anon / revoked / expired / user removedconst session = await getSessionAndUser(token, { sessions, db })// ^ { user: { id, roles: string[] }, expires: number } | nullgetSessionAndUser(token, bindings) is the whole read in one call: it reads the KV record (SessionRecord = { userId, expires }), rejects it if expired, looks up the D1 users row, splits roles, and returns the resolved Session — or null for any failure along the chain. Because the KV key is server-revocable, deleting session:<token> makes the very next read null — that’s how a revoke (and the live kill switch) works.
columns — widening the projection
Section titled “columns — widening the projection”The base read selects id, roles. An optional third argument widens it with app-owned fields, typed onto user:
const SESSION_COLUMNS = ['org_id', 'status', 'email'] as const
const session = await getSessionAndUser(token, { sessions, db }, { columns: SESSION_COLUMNS })// ^ user is { id: string; roles: string[] } & { org_id: string | null; status: … ; email: … }They are close to free: this row is fetched on every authenticated request anyway, and SQLite reads the whole page regardless of the SELECT list, so the marginal cost of a small scalar is only serialising it across the D1 boundary. The bar is size, not whether the value gates a decision — the alternative is a second query on the hot path for a row already in hand. A JSON blob or long text is paid on every authenticated request and belongs in its own query.
Declare the list once and share it across every call site (the route guard, each use: [session] provider, any require: gate), or the session shape can differ between them.
| Export | Kind | Role |
|---|---|---|
getSessionAndUser(token, bindings, opts?) | fn | the resolver — cookie token → Session | null; opts.columns widens user |
SessionRecord | type | the Rust↔TS KV wire contract — { userId, expires } |
SessionBindings | type | the { sessions, db } the resolver needs (duck-typed KV/D1) |
sessionKey(token) | fn | `session:${token}` — the KV key convention |
cookieName(prod) | fn | __Secure-session (prod) / session (dev http) |
App auth config
Section titled “App auth config”A consumer’s src/auth.ts binds the generic resolver to its own KV/D1 and exports the app’s auth config via Auth({ session }) — one of the three once-per-app config helpers (Env / Transport / Auth):
import { getSessionAndUser } from '@madenowhere/phaze-auth'import { Auth } from '@madenowhere/phaze-cloudflare/auth'import { broadcast } from '@madenowhere/phaze-cloudflare/transport'import { env } from 'phaze:env'
export const COOKIE = env.prod ? '__Secure-session' : 'session' // __Secure- needs HTTPSexport type { SessionRecord } from '@madenowhere/phaze-auth'
// bind the app's KV/D1 instances to the generic resolverconst bindings = (transport: Env) => ({ sessions: transport.SESSIONS, db: transport.DB })
// (1) the guard resolver — core() calls this PRE-STREAM in the isomorphic// lifecycle (before any shell flushes), in-process (no wire)export default Auth<Env>({ session: ({ cookies, transport }) => getSessionAndUser(cookies.get(COOKIE), bindings(transport)),})
// (2) the `use:[session]` provider — a plain fn; being in a fence's use:[] IS what// makes it a provider. Returns { session }, folded onto the handler ctx.export const session = async (_input: unknown, { cookies, transport }: ActionContext) => ({ session: await getSessionAndUser(cookies.get(COOKIE), bindings(transport)),})
// (3) the WRITE counterpart — revoke: delete the KV record AND push its live streams,// then clear the cookie. broadcast() kicks any open SSE connection (a bare delete// revokes server-side but wouldn't close a held stream). Idempotent.export const revokeUserSession = async (_input: unknown, { cookies, transport }: ActionContext) => { const token = cookies.get(COOKIE) if (token) { await transport.SESSIONS.delete(`session:${token}`) broadcast(`session:${token}`, 'null') } cookies.delete(COOKIE, { path: '/' })}The read lives once (getSessionAndUser) and is shared two ways with no duplication: the restricted guard calls it directly (PRE-STREAM in the isomorphic lifecycle — before any shell flushes; in-process, no fetch/sign/envelope), and Session.request pulls it in over the signed wire via use:[session]. The two “streams” are unrelated: the guard’s is the SSR HTML shell, Session.request’s is the transport. revokeUserSession is the write side, wired into Session.delete via use:[revokeUserSession].
Browser ceremony
Section titled “Browser ceremony”The client entry (@madenowhere/phaze-auth/browser) wraps navigator.credentials into two ceremony calls plus the base64url codecs WebAuthn needs:
import { createPasskey, getPasskey } from '@madenowhere/phaze-auth/browser'
// register: pass the server's start options → returns the finish payload to post backconst finish = await createPasskey(startOptions) // navigator.credentials.create(...)
// login: pass the server's start options → returns the assertion to post backconst assertion = await getPasskey(startOptions) // navigator.credentials.get(...)| Export | Role |
|---|---|
createPasskey(opts) | the register ceremony → { id, attestationObject, clientDataJSON, transports } |
getPasskey(opts) | the login ceremony → { id, authenticatorData, clientDataJSON, signature } |
b64urlToBuf · bufToB64url | base64url ⇄ ArrayBuffer — challenge/credential-ID encoding |
PasskeyRegisterStart/Finish · PasskeyAuthStart/Finish | ceremony types (compatible with the Rust-gen action outputs) |
| — | fingerprint() moved to its own subpath, /fingerprint — see Abuse protection |
Abuse protection
Section titled “Abuse protection”Three primitives raise the cost of automated signup and login abuse. Like the resolver, none of them is a framework you configure — they are plain functions that return plain values, and the app decides what a value means. Two are server-side; one runs in the browser.
rateLimitBy — the keyed counter
Section titled “rateLimitBy — the keyed counter”A KV-backed counter under a key you choose, with a rolling window:
import { rateLimitBy } from '@madenowhere/phaze-auth'
const ok = await rateLimitBy(kv, `req:email:${email}`, 5, 3600)// ^ false once the 6th call inside the hour arrivesIt returns a boolean and never throws — deliberately, so it stays independent of any host’s
error type. Turning “over the cap” into a rejection is the app’s decision, which usually means
currying it into a require: gate:
export const rateLimitBy = (by: 'email' | 'ip', cap: number) => async (input: { email: string }, ctx: ActionContext) => { const subject = by === 'email' ? input.email : ipOf(ctx.request) if (!(await checkRateLimit(ctx.transport.OTP_KV, `req:${by}:${subject}`, cap))) throw new ActionError({ code: 'TOO_MANY_REQUESTS', message: 'Too many requests.' }) }require: [turnstile, rateLimitBy('email', 5), rateLimitBy('ip', 20)]rateLimit — the external: edge policy
Section titled “rateLimit — the external: edge policy”The same idea as a declared value rather than a call — requests per minute per client IP, enforced by the generated worker entry before the handler runs:
external: [rateLimit(5), cors()]It applies to the public face of a fence (the plain GET /api/<group>/<action> an external:
fence compiles to). A fence with transport: rpc has no public wire, so declaring it there is a
compile error — those fences hand-roll a require: gate instead.
fingerprint — device identity
Section titled “fingerprint — device identity”fingerprint() (from /fingerprint, its own subpath) returns a stable, opaque
<measured>.<token> pair identifying the machine and the browser profile, for flows that
must not be repeatable by the same person under a different address:
import { fingerprint } from '@madenowhere/phaze-auth/fingerprint'
const fp = s.async(fingerprint(device.token)) // null under SSR; `.<token>` (token axes only) without WebGL…await request.execute({ email: email(), turnstileToken: token(), fingerprint: fp.value() })The call costs the eager bundle nothing. phaze-compile rewrites it to a guarded dynamic
import: the measurement rides its own lazy chunk (◐ hydrates, named by the module), the static
specifier is pruned, and the server resolves the guard’s null branch — no WebGL code on the
first-paint path, none in the Worker. You write a plain call; the compiler owns the delivery.
Two precisions in one value. The measured half describes a machine class and can collide;
the token is assigned and cannot. So an exact match is proof of one browser profile and may
act alone, while a prefix (class) match is evidence and should demand a second independent
fact — pair it with the network (request.cf.asn), so a false match needs two coincidences
instead of one.
Where the identity survives — the protection stack
Section titled “Where the identity survives — the protection stack”Declare the capability once — fingerprint: true in auth.ts ships the client half and the
adapter serves the token route, whose path and cache lifetime are configurable:
export default Auth<Env>({ session, fingerprint: true })
// vite.config.ts — path + maxAge optional (defaults: /_phaze/fp, one year)cloudflare({ fingerprint: { path: '/icon.ico', maxAge: years(1) } })The route answers Cache-Control: private, max-age=1y, immutable with CDN-Cache-Control: no-store
— the two-tier split from caching. The browser’s own HTTP cache
becomes the store: a return visit replays the original token without ever reaching the Worker,
while no shared cache holds anyone’s. It behaves as storage precisely by ceasing to be a request.
The response is a favicon — image/x-icon, a real 16×16 icon with the token appended to its
bytes (an ICO parser ignores the trailing bytes, so the icon still renders; the client reads the
raw body and takes the tail). Pasting the route in a browser shows a mundane icon, never the token —
the content-type is the disguise, not encryption. So a consumer serves it at a path that reads as an
ordinary site icon, e.g. /icon.ico.
The module’s token chain: a caller-supplied token wins when it is a valid token ({1,16}
base64url); a stale or corrupt one — a salt left in the app store from before the salt→token rename,
say — is discarded rather than trusted, so one poisoned axis can’t corrupt the value. With no valid
caller token the cached route answers; route absent (fingerprint not enabled) or unreachable, a
local mint. The module keeps no storage of its own — persistence belongs to the caller, which is
how the stores restore each other:
| store | written by | survives |
|---|---|---|
| HttpOnly cookie | the server, inside the action that spends the real cost | JavaScript off, a network change, a browser restart — and it is authoritative: no page script can read or rewrite it, so a forged claim cannot displace a browser you already know |
app store (store(…, key)) | the app, adopting the resolved token | a cookie clear, a cache eviction |
| HTTP cache | the browser, caching the token route’s favicon response | a localStorage clear |
None is durable — one “clear browsing data” takes them all, and that is fine. The point is not to be unclearable; it is to survive the partial clears people actually perform, and to degrade to the measured half rather than to nothing. (IndexedDB is deliberately absent: the same button clears it as localStorage, so it duplicates coverage. TLS signatures are the wrong axis entirely — a JA3-style hash identifies the browser build, not the device.)
The cookie also survives a change to what fingerprint() measures: a browser update that
shifts a WebGL limit moves the measured half for everyone, and cookie-holders keep being
recognised regardless. That reads like staleness and is resilience — the value can only ever be
that browser’s, since it was minted there.
Adopt the token — never mint it in the component body:
const device = store({ token: '' }, 'app:device')device.load()const fp = s.async(fingerprint(device.token))// Self-quiescing: the write falsifies its own condition. A bare read-then-write of the same// field in the body re-runs the tracked frame forever on the server, where load() restores// nothing — the store contract's "value is simply absent server-side".watch(!device.token && fp.value() && (device.token = fp.value()!.slice(fp.value()!.indexOf('.') + 1), device.save()))It hashes WebGL 1 hardware limits — thirteen of them, from MAX_TEXTURE_SIZE to
ALIASED_POINT_SIZE_RANGE — plus platform, devicePixelRatio and timezone. The result is
base64url, safe for a URL, a header, or Turnstile’s cdata.
The selection rule is narrow, and everything else follows from it: a field qualifies only if every engine reports it and none can shade it. Being widely implemented is not enough. A value with no functional consequence gets clamped or withheld for anti-fingerprinting, and a clamped field splits the hash on one machine exactly as a different machine would. WebGL constants pass because a browser cannot lie about them — real WebGL programs allocate against them.
Measured rejections, each verified across Chrome and Safari on one Mac rather than assumed:
| Rejected | Why |
|---|---|
navigator.hardwareConcurrency | Safari reports 8 where the machine has 14 — implemented everywhere and deliberately reduced |
navigator.deviceMemory | Chromium-only; undefined in Safari and Firefox |
screen.colorDepth | measured 30 in Chromium, 24 in Safari, on the same HDR display |
screen.width / height | Brave normalises both to a stock 1920x1080 — see the caution below |
| fonts | on macOS the installed set is the system set: identical on every Mac, so zero entropy |
| AudioContext | Safari and Chromium agree only to ~1e-7 (needs quantising), and Brave farbles it outright |
enumerateDevices() counts | Safari withholds audio outputs without permission — reports 0 where Chromium reports 1 |
WebGPU adapter.info / WEBGL_debug_renderer_info | high entropy, no functional role — each engine redacts to taste |
| WebGL 2 parameters (all of them) | origin/session-farbled: Brave perturbs above-spec-floor WebGL2 values under a seed keyed per origin and per session, and the target field moves — the components pair on one origin, MAX_UNIFORM_BUFFER_BINDINGS on another (measured 2026-08-22, each identified by hash-matching candidate materials). A moving target survives no field list, so the class hashes WebGL 1 only |
| WebGPU itself | support is uneven, and an absent capability splits the hash like a different machine |
| canvas / render output | rasterisation genuinely differs by engine (ANGLE vs direct GL) — a browser fingerprint wearing a hardware one’s clothes |
| network (ASN) | available server-side on request.cf, but a laptop would hash differently at home and at the office |
| TLS handshake (JA3/JA4) | identifies the browser build, not the device — two people on the same Chrome version produce the same signature on different machines |
Keep the excluded signals beside the value if you want them. Never inside it.
The value is a claim, not proof: a script can send any string. Pair it with a bot check
(require: [turnstile]) so submitting one at all costs something, and derive the stored form
server-side rather than storing what arrived:
// early_access.ts — HKDF under a secret only the server holds. The stored value is not// reversible to the signals, not linkable to any other site's hash, and useless to anyone// who reads the table without the key. Give it its OWN secret: a key that signs redirects// should not also derive identifiers.const deviceId = async (claim: string) => … // crypto.subtle HKDFActing on a match — record what the gate saw
Section titled “Acting on a match — record what the gate saw”A gate that only writes when it fires cannot be audited: a pipeline failure looks like an
ordinary signup, and the block leaves nothing at all. So every row records what its
fingerprint was created from — the technology, named, because a technology word means
exactly one thing — in a fingerprint_type column:
| value | the fingerprint was created from |
|---|---|
cookie | the HttpOnly cookie the server wrote on an earlier visit — the server’s own authoritative copy; also the healthy return visit where a fresh reading arrived and agreed |
webgl | the browser’s WebGL measurement plus its token — the ordinary first visit |
token | the stored token alone — a browser with no WebGL; still proof of the exact browser profile, with no machine-class tier |
mismatch | a cookie and a WebGL reading both arrived and disagreed — the cookie stays authoritative, this records that the fresh reading contradicted it |
none | nothing arrived on any axis — a scripted call omitting the field, the chunk blocked, a race — fails open, and the rate of none is how signal-stripping becomes measurable |
The decision itself is two tiers with different burdens of proof:
- Exact match — the full
<measured>.<token>equals another verified row’s → same browser. Blocks alone. A minted token cannot collide by chance, so a hit is proof rather than evidence — and because the token travels with the browser, this tier catches the VPN and hotspot case the measured half structurally cannot. - Class match — the measured prefix matches but the token differs → same machine class (a
second browser, or cleared storage that minted a fresh token). This can be wrong about a
stranger on similar hardware, so it blocks only when the network also matches
(
request.cf.asnagainst thesignup_asnrecorded at registration) — a false block then needs two coincidences. A class match on a different network is allowed through — and needs no marker: it writes a full row, so the near-miss pairs are reconstructible with a self-join over fingerprint prefixes andsignup_asn.
A blocked attempt writes no row of its own — instead a blocked_attempts counter
increments on the row that caused the refusal. That counter is the false-positive detector:
one or two means a person trying a second address; a number that keeps climbing on one row
means the fingerprint is matching strangers, and every one of them was turned away silently.
Enforcement reads only verified = 1 rows, so typos and abandoned attempts never punish the
corrected retry, and login-intent requests skip the gate entirely — the person already holds
a verified row.
This is fraud-detection architecture, deliberately: a signal, a derived identifier, a tiered decision with asymmetric burdens, and an evidence trail a human can audit. What separates it from naive blocking is the same discipline fraud systems live by — record everything, act only on proof or paired evidence, keep the gate’s mistakes visible, and fail open.
First-class fingerprinting
Section titled “First-class fingerprinting”Commercial device-intelligence products sell this stack as a subscription: a client probe, a server-side derivation, network corroboration, persistence engineered to survive clearing, and a fraud dashboard to watch it all. phaze-auth ships the same surface as a framework capability — one flag, one call, everything above compiled in:
- a measurement built only from signals every engine reports honestly, delivered as its own lazy chunk at zero eager cost;
- two precisions in one value — a machine class and, through the assigned token, proof of one browser profile;
- persistence across all four axes — the HttpOnly cookie, the app store, the browser’s own HTTP cache (served as a favicon), and the measured half itself — each axis cleared by a different gesture and each restored from the others, so the identity survives every partial clear people actually perform — and the axes degrade independently: a browser without WebGL still carries a proof-grade token identity, and a corrupt token in one store is discarded so the others answer rather than the whole value failing;
- tiered enforcement with corroboration built in — proof may act alone, evidence must agree with the network;
- and the piece vendors call fraud analytics: an audit trail (
fingerprint_type,blocked_attempts) that makes the gate’s own error rate a measurable number in your own database rather than a promise in someone else’s.
What no vendor ships either is a smaller ceiling: the most mature open-source probe, using every signal there is, reports ~80% uniqueness for the measured class, worse on Mac and Safari — the physics of client-side measurement, not a quality gap. The surface above is shaped by taking that ceiling seriously rather than advertising around it:
- The pairing is built in. The class tier blocks only when the network agrees
(
request.cf.asnagainst the recordedsignup_asn), so a false block needs two coincidences — and the deliberate consequence is a gate that under-fires, which is the right direction when you cannot measure your own error rate. (The opposite of capping by IP, which punishes everyone behind one carrier.) - The token is what upgrades a match to proof. The measured half alone is never an
identity; the full
<measured>.<token>pair identifies one browser profile exactly, because an assigned token cannot collide by chance. That is the only tier allowed to act alone. - The stored identity cannot be lifted from the client. Everything the browser holds is readable — the measurement, the token, the route it fetches — and none of it is the value the gate keeps. That value is an HKDF derivation under a secret that never leaves the server, domain-separated per app, so reading the entire client yields only a claim: not reversible to the signals behind it, not linkable to the same browser on another site, and unreadable in the table without the key. The secret isn’t in the client to find.
- Enforcement is auditable, never silent. The gate records
fingerprint_typeon every row and counts each refusal on the row that caused it — a blocked visitor is visible in the data, and a fingerprint matching strangers announces itself as a climbing counter. The real cost to avoid was never blocking; it was blocking invisibly. - The TLS avenue is closed here, structurally — checked, not assumed. Cloudflare
terminates TLS at the edge, so a Worker never sees resumption state; and the reachable
field (
tlsExportedAuthenticator) hashes the client’s TLS stack, which identifies the browser build — two people on the same Chrome version collide across machines, the exact inverse of a device signal. - When it must actually hold, stop measuring and start requiring. A phone number, a payment method, or an invite that someone has to spend on the newcomer. Each costs the visitor something, which is precisely why they work and a hash does not.
The native client can measure what a browser cannot
Section titled “The native client can measure what a browser cannot”A terminal has no WebGL and no cookie jar, so the browser’s value is not available to it — but the constraint that shapes that value is not available to it either, and that is the interesting half.
The browser’s measurement is built from declared limits and deliberately excludes render output, because “rasterisation genuinely DOES differ across engines (ANGLE vs direct GL, differing float compilation), so a render hash is a browser fingerprint wearing a hardware one’s clothes.” That is a statement about engines: Chrome renders through ANGLE and Safari does not, so one machine hashes two ways. Declared limits are what survives that — at the cost of identifying a GPU class, which is why the measured half is only ever evidence.
A native process has one rendering path. So phaze::auth::fingerprint::machine() uses the signal the
browser has to refuse:
- what the GPU computes — a fragment shader runs transcendentals in a loop, renders to a small float framebuffer, and the raw bytes are hashed. Precision, polynomial approximations and whether a multiply-add is contracted all differ by GPU and driver, so the value is what the hardware does rather than what it says;
- the platform UUID — per-unit rather than per-class, which removes the collision the browser’s class tier has to live with.
Either axis alone still produces a value. machine() returns the two hashed together and a label
naming which answered — both / uuid / gpu — because the digest cannot carry it, and a caller
that records only the hash cannot tell a healthy fleet from one where GPU access has quietly failed. The GPU
half moves on a driver update; the platform UUID does not — so a caller that wants continuity across
one pairs this with a small assigned token of its own, and the halves fail in opposite directions
exactly as the browser’s measured half and token do. Where that token lives is the caller’s choice;
phaze-auth does not store it, for the same reason it does not store the browser’s.
It remains a claim, exactly as the browser’s is: the binary chooses what to send. What it raises is the cost of forging one — from clearing a directory to patching a signed binary.
| Export | Entry | Role |
|---|---|---|
rateLimitBy(kv, key, cap, window?) | . | KV keyed counter → boolean; never throws |
rateLimit(limit) | . | the external: edge policy value — requests/min/IP |
fingerprint(token?) | /fingerprint | device identity → Promise<string | null>; compile-lazy, SSR-safe, fails open |
End-to-end usage
Section titled “End-to-end usage”The (experimental)/passkey page is the full surface — register, login, a reactive session, a push-driven live session with a revoke kill switch, and a TOTP wizard. The auth-relevant skeleton:
import { s, watch } from '@madenowhere/phaze/dsl'import { minutes, timeout, ms } from '@madenowhere/phaze/time'import { actions, streams } from 'phaze:transport'import { useAction } from '@madenowhere/phaze-cloudflare/actions'import { createPasskey, getPasskey } from '@madenowhere/phaze-auth/browser'import { COOKIE } from '@/auth'
---data// server loader (pageCtx → cookies + transport): the direct KV read, already-there on hydratekv_session: transport.SESSIONS.get(`session:${cookies.get(COOKIE)}`, 'json')---state// the action-backed reactive session: SSR-seeded + revalidated (re-reads KV→D1 = auto revoke/expiry)session: s.async(actions.Session.request(), { revalidate: minutes(10) })status: s('idle')---
const pkRegisterStart = useAction(actions.Passkey.registerStart)const pkRegisterFinish = useAction(actions.Passkey.registerFinish)
const register = async () => { const start = await pkRegisterStart.execute() // Rust start → typed options if (start.error || !start.data) return status.set('register failed') const result = await pkRegisterFinish.execute(await createPasskey(start.data)) // ceremony → finish if (result.error || !result.data) return status.set('register failed') status.set(`signed in as ${result.data.userId}`) session.reload() // re-read the reactive session live.reconnect() // re-open the live stream with the fresh cookie}
// THE LIVE BIND — push-driven session over SSE (s.bind compiles to a 0-byte EventSource recipe).// Type comes from the ---Session fence's out: → Signal<Session | null>.const live = s.bind(streams.Session())const wasAuthed = s(false)
// THE KILL SWITCH — latch on auth, fire on loss. SSR-safe: live() is undefined server-side, so// neither watch fires there. revokeUserSession's broadcast() pushes null here → redirect.watch(live()?.user && wasAuthed.set(true))watch(wasAuthed() && live() === null && timeout(ms(300), location.assign('/login')))The flow, end to end:
actions.Passkey.registerStart/loginStart— Rust-proxied transport fences return typedPublicKeyCredential*Options; the page never mirrors the types.createPasskey/getPasskey— the browser ceremony turns those options into the finish/assertion payload.*Finish— mints the KV session (the Rust handler’sSet-Cookieis relayed by the fence);getSessionAndUserreads it from then on.session(s.async) — the reactive session, SSR-seeded and revalidated;session.reload()forces a re-read after an auth transition.live(s.bind(streams.Session())) — the push channel:revokeUserSession’sbroadcastpushesnull, the kill-switchwatchfires, the page redirects — no polling, no re-read loop.
TOTP (the authenticator-app wizard on the same page) follows the identical consume shapes against the Totp.* fences — s.async(actions.Totp.enrollStart()) for the QR + secret, useAction(actions.Totp.enrollFinish/verify) for the 6-digit confirm.
The Rust crate
Section titled “The Rust crate”The heavy verification lives in the phaze-auth crate, re-exported at phaze::auth::* when the phaze crate is built with features = ["auth"]:
use phaze::auth::session::{SessionRec, ChallengeRec, random_b64url, b64url_encode};use phaze::auth::{totp, webauthn};| Module | What it is |
|---|---|
phaze::auth::session | the SessionRec / ChallengeRec schema + token / base64url helpers (the Rust side of the SessionRecord contract) |
phaze::auth::webauthn | passkey parse_attestation_object (register) + verify_assertion (login) — ES256/CBOR-COSE over phaze::crypto::p256 |
phaze::auth::totp | RFC-6238 enroll / verify — HMAC-SHA1 (authenticator-app standard) |
Two more exist only on macOS, gated on cfg(target_os) rather than a Cargo feature — they declare their own FFI and take no dependencies, so on wasm32 (the workerd build) they simply do not exist and there is no flag anyone can wrongly enable:
| Module | What it is |
|---|---|
phaze::auth::enclave | a Secure-Enclave P-256 key — the hardware backend for authenticator::Signer, non-exportable and presence-gated |
phaze::auth::fingerprint | machine() — this machine as a hash plus the axes that produced it: its platform UUID and what its GPU computes. The native counterpart to the browser’s fingerprint() |
The first three are pure-Rust with no Worker dependency, so they compile and unit-test on the native target (cargo test) without a wrangler environment — the verification cores travel with their own tests. The handler code that wires them to KV/D1 (the four passkey endpoints + the three TOTP endpoints) lives in the consumer’s rust-api/src/lib.rs; the Passkeys page covers the assertion-verify checks in depth.
Where it lives
Section titled “Where it lives”- Server primitives:
@madenowhere/phaze-auth—getSessionAndUser,SessionRecord,sessionKey,cookieName,rateLimitBy,rateLimit. - Browser ceremony:
@madenowhere/phaze-auth/browser—createPasskey,getPasskey, the base64url codecs. - Device identity:
@madenowhere/phaze-auth/fingerprint—fingerprint(token?), compiled lazy; the adapter serves its token route (a configurable-pathimage/x-iconfavicon, default/_phaze/fp) whenAuth({ fingerprint: true }). - App config:
src/auth.ts—Auth({ session })(the guard resolver) + thesession/revokeUserSessionproviders; importsAuthfrom@madenowhere/phaze-cloudflare/auth,broadcastfrom@madenowhere/phaze-cloudflare/transport. - Rust cores: the
phaze-authcrate →phaze::auth::{session, totp, webauthn}(features = ["auth"]); handlers inrust-api/src/lib.rs.
See also
Section titled “See also”- Passkeys (WebAuthn) — the four endpoints, the assertion-verify checks, RP-ID scoping,
noneattestation. - Phaze Transport — the
Session.*/Passkey.*/Totp.*fences,use:[]providers,rust:proxying, ands.bindstreams. - Router → Authorization — the per-route
restrictedguard that reads the resolver pre-stream, in the isomorphic lifecycle. - Signing & verification — the
sign:field onSession.requestand the live session stream.