WebAuthn & FIDO2 Ceremony
Muljax ID implements a zero-trust, passwordless authentication subsystem adhering to the W3C Web Authentication (WebAuthn Level 3) and FIDO Alliance CTAP 2.1 specifications. It leverages hardware-bound cryptographic credentials, platform biometric authenticators (Touch ID, Windows Hello, Face ID), roaming hardware security keys (YubiKeys), and atomic challenge lifecycles in Cloudflare D1.
1. Governing Protocol Standards
Section titled “1. Governing Protocol Standards”The WebAuthn implementation operates across the following standards:
- W3C Web Authentication (WebAuthn Level 3): Browser JavaScript APIs (
navigator.credentials.createandnavigator.credentials.get) and server validation rules. - FIDO Alliance CTAP 2.1: Client to Authenticator Protocol for communicating with USB, NFC, and Bluetooth authenticators.
- RFC 8152: CBOR Object Signing and Encryption (COSE): Key representation format for public keys extracted from authenticators.
- RFC 4648: Base64URL encoding used for binary credential IDs and cryptographic challenges.
2. Database Schema & Challenge Lifecycle
Section titled “2. Database Schema & Challenge Lifecycle”WebAuthn challenges are strictly ephemeral and single-use. The passkey_challenges table tracks active challenges with a hardcoded 5-minute TTL (CHALLENGE_DURATION = 5 * 60 * 1000):
CREATE TABLE passkey_challenges ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, challenge TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL);Atomic Challenge Consumption
Section titled “Atomic Challenge Consumption”To prevent race conditions and challenge replay attacks, challenges are consumed using SQLite’s atomic DELETE ... RETURNING * in apps/api/src/lib/passkey.ts:
// Registration challenge consumption (scoped to authenticated user)export async function consumeChallenge(db: ReturnType<typeof createDb>, userId: string) { const deleted = await db .delete(passkeyChallenges) .where( and( eq(passkeyChallenges.userId, userId), gt(passkeyChallenges.expiresAt, Date.now()), ), ) .returning(); return deleted[0] ?? null;}
// Userless login challenge consumption (scoped by challenge UUID)export async function consumeChallengeById(db: ReturnType<typeof createDb>, id: string) { const deleted = await db .delete(passkeyChallenges) .where( and( eq(passkeyChallenges.id, id), isNull(passkeyChallenges.userId), gt(passkeyChallenges.expiresAt, Date.now()), ), ) .returning(); return deleted[0] ?? null;}Because the row is deleted in the exact same query that retrieves it, a challenge can never be used more than once, even under concurrent requests.
3. Registration Ceremony (Attestation)
Section titled “3. Registration Ceremony (Attestation)”Registration binds a new public/private keypair inside the user’s authenticator to their account.
sequenceDiagram
autonumber
actor User as User
participant Browser as Browser (Client)
participant API as ID API Worker
participant D1 as Cloudflare D1
participant Enclave as Device Enclave / Key
Browser->>API: 1. POST /api/passkeys/register/options (Session Cookie)
API->>API: Verify session cookie
API->>D1: Select existing passkey credential IDs for user
API->>API: generateRegistrationOptions({ rpName, rpID, excludeCredentials, ... })
API->>D1: INSERT into passkey_challenges (userId, challenge, expiresAt)
API-->>Browser: 2. 200 OK: PublicKeyCredentialCreationOptionsJSON
Browser->>Enclave: 3. navigator.credentials.create({ publicKey })
User->>Enclave: Authorize biometric scan / tap key
Enclave->>Enclave: Generate asymmetric keypair & sign clientDataJSON
Enclave-->>Browser: Attestation response (attestationObject, clientDataJSON)
Browser->>API: 4. POST /api/passkeys/register/verify { response, name }
API->>D1: 5. consumeChallenge(userId) (atomic DELETE ... RETURNING *)
API->>API: 6. verifyRegistrationResponse({ expectedChallenge, expectedOrigin, expectedRPID })
API->>D1: 7. INSERT into passkeys (credentialId, publicKey, counter, transports)
API->>D1: 8. emitNotification("security.passkey_added")
API-->>Browser: 9. 200 OK: { success: true }
Server Options Generation (POST /api/passkeys/register/options)
Section titled “Server Options Generation (POST /api/passkeys/register/options)”In apps/api/src/routes/passkeys/register/options/post.ts:
const existingPasskeys = await db .select({ credentialId: passkeys.credentialId }) .from(passkeys) .where(eq(passkeys.userId, user.id));
const options = await generateRegistrationOptions({ rpName: c.env.RP_NAME, rpID: c.env.RP_ID, userName: user.email, userDisplayName: user.email, excludeCredentials: existingPasskeys.map(({ credentialId }) => ({ id: credentialId, })), authenticatorSelection: { residentKey: "required", // Enforces discoverable credentials for userless login userVerification: "preferred", // Requests biometric verification when available }, attestationType: "none", // Eliminates unnecessary privacy-invasive tracking});
await createChallenge(db, user.id, options.challenge);Server Verification (POST /api/passkeys/register/verify)
Section titled “Server Verification (POST /api/passkeys/register/verify)”In apps/api/src/routes/passkeys/register/verify/post.ts:
- Atomically consumes the challenge from
passkey_challenges. - Validates origin matches
getDashboardOrigin(c.env)and RP ID matchesc.env.RP_ID. - Verifies cryptographic signature in
attestationObject. - Enforces uniqueness: checks that
credential.idis not already registered (409 Conflict). - Persists the credential in
passkeys:id: UUID v4credentialId: Base64URL credential stringpublicKey: Base64 encoded public key bytes (arrayBufferToBase64(credential.publicKey))counter: Initial integer sign counttransports: JSON array of supported transports (e.g.["internal"]or["usb", "nfc"])
4. Authentication Ceremony (Assertion)
Section titled “4. Authentication Ceremony (Assertion)”The platform supports completely userless (discoverable credential) login: the user does not need to type their email or username. The authenticator looks up the resident key matching the RP ID and returns the credential identifier.
sequenceDiagram
autonumber
actor User as User
participant Browser as Browser (Client)
participant API as ID API Worker
participant D1 as Cloudflare D1
participant Enclave as Device Enclave / Key
Browser->>API: 1. POST /api/passkeys/login/options {}
API->>API: generateAuthenticationOptions({ rpID, userVerification: "preferred" })
API->>D1: INSERT into passkey_challenges (userId: null, challenge, expiresAt)
API-->>Browser: 2. 200 OK: { challengeId, ...options }
Browser->>Enclave: 3. navigator.credentials.get({ publicKey })
User->>Enclave: Authorize biometric scan / tap key
Enclave->>Enclave: Look up resident key, increment signCount, sign clientData
Enclave-->>Browser: Assertion response (authenticatorData, signature, credential.id)
Browser->>API: 4. POST /api/passkeys/login/verify { challengeId, response }
API->>D1: 5. consumeChallengeById(challengeId) (atomic DELETE ... RETURNING *)
API->>D1: 6. SELECT passkey WHERE credentialId = response.id
API->>D1: 7. SELECT user WHERE id = passkey.userId
API->>API: 8. verifyAuthenticationResponse(...)
API->>API: 9. isUserDisabled(user) check
API->>D1: 10. UPDATE passkeys SET counter = newCounter, lastUsedAt = Date.now()
API->>D1: 11. INSERT into sessions (tokenHash, ipAddress, geolocation, userAgent)
API-->>Browser: 12. 200 OK + Set-Cookie: session=... (HttpOnly, Secure)
Assertion Verification Invariants (POST /api/passkeys/login/verify)
Section titled “Assertion Verification Invariants (POST /api/passkeys/login/verify)”In apps/api/src/routes/passkeys/login/verify/post.ts:
- Challenge Validity: Verifies that
challengeIdexists and is consumed atomically. - Credential Lookup: Locates the public key bytes stored in
passkeys.publicKey. - Cryptographic Signature Verification: Validates the signature over
authenticatorDataand SHA-256 hash ofclientDataJSON. - Origin Binding: Rejects the assertion if the origin in
clientDataJSONdoes not match the configured dashboard domain. - Account Status Guard:
if (isUserDisabled(user)) {return c.json({ error: "Your account has been disabled." }, 403);}
- Sign Counter Monotonicity: Checks that
newCounter > storedCounterto detect cloned authenticators. - Session Issuance: Generates a cryptographically random session token, hashes it with SHA-256 before inserting into
sessions, records Cloudflare edge geolocation metadata (cf.country,cf.city,cf.region), and sets the HttpOnly cookie.
5. WebAuthn Binary Data Layouts
Section titled “5. WebAuthn Binary Data Layouts”clientDataJSON
Section titled “clientDataJSON”UTF-8 JSON string created by the browser and hashed by the authenticator:
{ "type": "webauthn.get", "challenge": "dGVzdC1jaGFsbGVuZ2UtMTIzNDU2", "origin": "https://id.example.com", "crossOrigin": false}authenticatorData Binary Layout (CTAP 2.1 § 6)
Section titled “authenticatorData Binary Layout (CTAP 2.1 § 6)”The binary authenticatorData buffer returned by the authenticator is structured as follows:
| Byte Offset | Length | Name | Description |
|---|---|---|---|
0 |
32 bytes | rpIdHash |
SHA-256 hash of the Relying Party ID string (c.env.RP_ID). |
32 |
1 byte | flags |
Bitfield representing authenticator execution flags (see below). |
33 |
4 bytes | signCount |
32-bit unsigned big-endian integer incremented on each signature. |
37 |
k bytes |
attestedCredentialData |
(Registration only) Contains AAGUID (16 bytes), Credential ID length (2 bytes), Credential ID (n bytes), and COSE Public Key. |
Flags Bitfield Breakdown (flags)
Section titled “Flags Bitfield Breakdown (flags)”- Bit 0 (
UP): User Present. Set if physical presence was verified (e.g. key tap). - Bit 2 (
UV): User Verified. Set if biometric or PIN verification succeeded. - Bit 6 (
AT): Attested Credential Data. Indicates whether credential public key data is appended. - Bit 7 (
ED): Extension Data. Indicates whether authenticator extension data is appended.
6. Threat Model & Defense Mechanisms
Section titled “6. Threat Model & Defense Mechanisms”| Threat | Attack Vector | Mitigation in Muljax ID |
|---|---|---|
| Phishing / Credential Harvesting | Attacker lures user to fake domain (e.g. id-login.com). |
Authenticators bind signatures strictly to the TLS origin. The browser refuses to sign for incorrect domains. |
| Replay Attacks | Eavesdropper captures previous assertion payload and re-sends. | Challenges expire after 5 minutes and are consumed atomically via DELETE ... RETURNING *. |
| Cloned Authenticators | Private key extracted or cloned from physical device. | Monotonic sign counter validation detects duplicate counter values and immediately flags clone activity. |
| Database Compromise | Attacker obtains full read dump of Cloudflare D1. | Zero private keys exist in the database. Only public keys and credential IDs are stored. Sessions and tokens are stored as SHA-256 hashes. |
| Bypassing Disabled Accounts | Suspended user attempts to authenticate with a valid passkey. | API checks isUserDisabled(user) immediately after cryptographic verification and rejects with 403 Forbidden before creating a session. |