Registration Flow
The server side
WebAuthn registration has two server endpoints: one to generate options (the challenge), one to verify the response (the credential).
// src/routes/passkeys.ts
import { route, group } from "@hectoday/http";
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import db from "../db.js";
import { authenticate } from "../auth.js";
const RP_NAME = "Hectoday Course";
const RP_ID = "localhost";
const ORIGIN = "http://localhost:3000";
export const passkeyRoutes = group([
// Step 1: Generate registration options
route.post("/me/passkeys/register/options", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
// Get existing passkeys for this user (to exclude them)
const existing = db
.prepare("SELECT credential_id FROM passkeys WHERE user_id = ?")
.all(user.id) as { credential_id: string }[];
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName: user.email,
userDisplayName: user.name,
excludeCredentials: existing.map((p) => ({
id: p.credential_id,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
// Store the challenge temporarily
db.prepare(
"INSERT OR REPLACE INTO webauthn_challenges (user_id, challenge, expires_at) VALUES (?, ?, ?)",
).run(user.id, options.challenge, Date.now() + 5 * 60 * 1000);
return Response.json(options);
},
}),
// Step 2: Verify registration response
route.post("/me/passkeys/register/verify", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
// Retrieve the stored challenge
const challengeRow = db
.prepare("SELECT challenge, expires_at FROM webauthn_challenges WHERE user_id = ?")
.get(user.id) as { challenge: string; expires_at: number } | undefined;
if (!challengeRow || Date.now() > challengeRow.expires_at) {
return Response.json({ error: "Challenge expired" }, { status: 400 });
}
try {
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challengeRow.challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified || !verification.registrationInfo) {
return Response.json({ error: "Verification failed" }, { status: 400 });
}
const { credential, credentialDeviceType } = verification.registrationInfo;
// Store the credential
const id = crypto.randomUUID();
db.prepare(
"INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?, ?)",
).run(
id,
user.id,
Buffer.from(credential.id).toString("base64url"),
Buffer.from(credential.publicKey).toString("base64url"),
credential.counter,
body.name ?? `Passkey (${credentialDeviceType})`,
);
// Clean up the challenge
db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ?").run(user.id);
return Response.json({
message: "Passkey registered successfully.",
id,
});
} catch (error) {
return Response.json({ error: "Verification failed" }, { status: 400 });
}
},
}),
]); What each part does
generateRegistrationOptions: Creates the challenge and parameters the browser needs to create a credential. excludeCredentials prevents the user from registering the same authenticator twice. residentKey: "preferred" requests a discoverable credential (passkey), but falls back to a non-discoverable one if the authenticator does not support it.
The challenge: A random value stored temporarily on the server. The browser includes it in the credential, and the server verifies it to prevent replay attacks. Challenges expire after 5 minutes.
verifyRegistrationResponse: Validates the browser’s response: checks the challenge matches, the origin matches (localhost in development), and the attestation is valid. Returns the public key and credential ID.
Storing the credential: The public key and credential ID are stored in the passkeys table. The counter starts at 0 and increments with each use (used to detect cloned authenticators).
The client side
The browser code calls the WebAuthn API:
// Client-side JavaScript
// Step 1: Get options from the server
const optionsRes = await fetch("/me/passkeys/register/options", { method: "POST" });
const options = await optionsRes.json();
// Step 2: Create the credential (triggers biometric prompt)
const credential = await navigator.credentials.create({ publicKey: options });
// Step 3: Send the response to the server
const verifyRes = await fetch("/me/passkeys/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
}); [!NOTE] In practice, the
optionsneed to be transformed between the server’s JSON format and the WebAuthn API’s format (some fields are base64url-encoded on the wire but need to be ArrayBuffers in the browser). The@simplewebauthn/browserpackage handles this conversion. For this course, we focus on the server side.
Exercises
Exercise 1: Call the registration options endpoint. Inspect the response: you should see a challenge, rp (relying party), user, and pubKeyCredParams.
Exercise 2: What is the rpID? It is the domain that the passkey is bound to. In development, it is localhost. In production, it would be yourapp.com.
Exercise 3: Why do we store the challenge in the database with an expiry? (Because the browser’s response comes in a separate request. We need to match the response to the challenge.)
What does the counter in the passkeys table prevent?