The project mandate was clear: build a new web application with an instantaneous login experience and ditch passwords entirely. The performance target led us straight to Qwik, with its promise of resumability and near-zero initial JavaScript. The passwordless requirement pointed directly to WebAuthn. The combination felt potent, but the initial spike revealed a significant architectural challenge. How does a multi-step, asynchronous, browser-native API like WebAuthn interact with Qwik’s unique execution model? Traditional client-side state management patterns don’t apply cleanly. The browser’s credential manager modal would pause our application’s flow, and we had to ensure Qwik could resume its state perfectly upon completion. This wasn’t a simple library integration; it was a deep dive into the intersection of two powerful but philosophically distinct technologies.
Our first step was establishing the server-side contract. The client needs to request challenges for registration and authentication, and then submit the browser’s response for verification. We used Qwik City’s route API endpoints for this, keeping the logic co-located with the front-end. We pulled in @simplewebauthn/server
for the heavy lifting of cryptographic operations.
Here’s the initial shape of our server-side logic. The key is to manage user state and their associated authenticators. For this build log, we’ll use a simple in-memory store, but in production, this would be a persistent database.
// src/lib/auth/server.ts
import type {
GenerateRegistrationOptionsOpts,
GenerateAuthenticationOptionsOpts,
VerifyRegistrationResponseOpts,
VerifyAuthenticationResponseOpts,
VerifiedRegistrationResponse,
VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { AuthenticatorDevice } from '@simplewebauthn/server/script/deps';
// This is a stand-in for a real database.
// In a production environment, use a persistent store like PostgreSQL or MongoDB.
interface User {
id: string;
username: string;
devices: AuthenticatorDevice[];
}
const userStore: Record<string, User> = {};
let currentChallengeStore: Record<string, string> = {};
// Relying Party (RP) configuration
const rpID = 'localhost';
const rpName = 'Qwik WebAuthn Demo';
const origin = `http://${rpID}:5173`;
/**
* Manages user creation and retrieval.
*/
const userManager = {
findOrCreate: (username: string): User => {
const existingUser = Object.values(userStore).find(u => u.username === username);
if (existingUser) {
return existingUser;
}
const id = `user_${Date.now()}`;
const newUser: User = { id, username, devices: [] };
userStore[id] = newUser;
return newUser;
},
getUser: (userId: string): User | undefined => userStore[userId],
getUserByUsername: (username: string): User | undefined => {
return Object.values(userStore).find(u => u.username === username);
},
addDeviceToUser: (userId: string, device: AuthenticatorDevice) => {
const user = userStore[userId];
if (user) {
user.devices.push(device);
}
},
};
/**
* Manages challenges to prevent replay attacks.
*/
const challengeManager = {
set: (username: string, challenge: string) => {
currentChallengeStore[username] = challenge;
},
get: (username: string): string | undefined => {
const challenge = currentChallengeStore[username];
// A challenge should only be used once.
delete currentChallengeStore[username];
return challenge;
},
clear: () => {
currentChallengeStore = {};
}
};
/**
* Generates registration options for a new user.
*/
export async function getRegistrationOptions(username: string) {
if (!username) {
throw new Error('Username is required.');
}
const user = userManager.findOrCreate(username);
const existingDevices = user.devices;
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: user.id,
userName: user.username,
timeout: 60000,
attestationType: 'none',
excludeCredentials: existingDevices.map(dev => ({
id: dev.credentialID,
type: 'public-key',
transports: dev.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
supportedJWSAlgIDs: [-7, -257],
};
const registrationOptions = await generateRegistrationOptions(options);
challengeManager.set(user.username, registrationOptions.challenge);
return registrationOptions;
}
/**
* Verifies the registration response from the client.
*/
export async function verifyNewRegistration(
responseBody: any,
username: string
): Promise<VerifiedRegistrationResponse> {
const user = userManager.getUserByUsername(username);
if (!user) {
throw new Error(`User not found: ${username}`);
}
const expectedChallenge = challengeManager.get(username);
if (!expectedChallenge) {
throw new Error('No challenge found for this user. Registration may have timed out.');
}
const verificationOpts: VerifyRegistrationResponseOpts = {
response: responseBody,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
};
const verification = await verifyRegistrationResponse(verificationOpts);
if (verification.verified && verification.registrationInfo) {
const { registrationInfo } = verification;
const newDevice: AuthenticatorDevice = {
credentialID: registrationInfo.credentialID,
credentialPublicKey: registrationInfo.credentialPublicKey,
counter: registrationInfo.counter,
transports: responseBody.response.transports || [],
};
userManager.addDeviceToUser(user.id, newDevice);
}
return verification;
}
/**
* Generates authentication options for an existing user.
*/
export async function getAuthenticationOptions(username: string) {
const user = userManager.getUserByUsername(username);
if (!user) {
throw new Error(`Could not find user '${username}'`);
}
const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: user.devices.map(dev => ({
id: dev.credentialID,
type: 'public-key',
transports: dev.transports,
})),
userVerification: 'preferred',
rpID,
};
const options = await generateAuthenticationOptions(opts);
challengeManager.set(user.username, options.challenge);
return options;
}
/**
* Verifies the authentication response from the client.
*/
export async function verifyExistingAuthentication(
responseBody: any,
username: string
): Promise<VerifiedAuthenticationResponse> {
const user = userManager.getUserByUsername(username);
if (!user) {
throw new Error(`User not found: ${username}`);
}
const expectedChallenge = challengeManager.get(username);
if (!expectedChallenge) {
throw new Error('No challenge found for this user. Authentication may have timed out.');
}
const device = user.devices.find(dev => dev.credentialID === responseBody.id);
if (!device) {
throw new Error(`Could not find authenticator with ID ${responseBody.id} for user ${user.username}`);
}
const verificationOpts: VerifyAuthenticationResponseOpts = {
response: responseBody,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: device,
requireUserVerification: true,
};
const verification = await verifyAuthenticationResponse(verificationOpts);
if (verification.verified) {
// Update the authenticator's counter
device.counter = verification.authenticationInfo.newCounter;
}
return verification;
}
With the server logic defined, we moved to the Qwik component. This is where the core challenge resides. We needed a single component to handle both registration and login, manage loading states, display errors, and most importantly, orchestrate the client-side WebAuthn ceremony without disrupting Qwik’s state model.
We structured the component using useStore
for reactive state. The onClick$
handlers are where the magic happens. These handlers are serializable by Qwik’s optimizer and can be “resumed” on the client, bringing their closed-over state with them.
The first major hurdle was the data format. The WebAuthn API works with ArrayBuffer
objects, which are not JSON-serializable. We had to create utility functions to convert ArrayBuffer
to Base64URL encoding before sending data to the server, and vice-versa when receiving challenges.
// src/lib/auth/base64url.ts
/**
* Converts a buffer to a Base64URL string.
*/
export function bufferToBase64URL(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let str = '';
for (const charCode of bytes) {
str += String.fromCharCode(charCode);
}
const base64 = btoa(str);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
/**
* Converts a Base64URL string to a buffer.
*/
export function base64URLToBuffer(base64url: string): ArrayBuffer {
// Add padding
base64url = base64url.padEnd(base64url.length + (4 - (base64url.length % 4)) % 4, '=');
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const str = atob(base64);
const buffer = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
buffer[i] = str.charCodeAt(i);
}
return buffer.buffer;
}
Now, the main component. The implementation is intricate. Notice how we recursively process the challenge object to convert any ArrayBuffer
values. The coolest part is how Qwik handles the await startRegistration(options)
call. The onClick$
handler execution pauses, the browser shows the security key/biometric prompt, and once the user interacts, the promise resolves, and the handler continues executing with its state intact. Qwik’s resumability means this complex client-side interaction doesn’t require a full-blown SPA hydration model. The code for the event handler is downloaded just-in-time.
// src/components/auth/webauthn-form.tsx
import { component$, useStore, $ } from '@builder.io/qwik';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import { bufferToBase64URL, base64URLToBuffer } from '~/lib/auth/base64url';
interface WebAuthnFormState {
mode: 'register' | 'login';
username: string;
isLoading: boolean;
error: string | null;
successMessage: string | null;
}
// Helper to deep-convert Base64URL strings in the challenge object to ArrayBuffers
const deepBase64URLToBuffer = (obj: any): any => {
if (typeof obj === 'string') {
// A simple heuristic to detect Base64URL. This could be more robust.
if (obj.length > 10 && /^[A-Za-z0-9\-_]+$/.test(obj)) {
try {
return base64URLToBuffer(obj);
} catch (e) {
// Not a valid base64url string, return as is
return obj;
}
}
}
if (Array.isArray(obj)) {
return obj.map(deepBase64URLToBuffer);
}
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, deepBase64URLToBuffer(value)])
);
}
return obj;
};
export const WebAuthnForm = component$(() => {
const state = useStore<WebAuthnFormState>({
mode: 'register',
username: '',
isLoading: false,
error: null,
successMessage: null,
});
const handleRegistration = $(async () => {
state.isLoading = true;
state.error = null;
state.successMessage = null;
try {
// 1. Get registration options from the server
const respOptions = await fetch('/api/auth/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: state.username }),
});
if (!respOptions.ok) {
const errorData = await respOptions.json();
throw new Error(errorData.message || 'Failed to get registration options');
}
let options = await respOptions.json();
options = deepBase64URLToBuffer(options);
// 2. Prompt the user to create a passkey with the browser's WebAuthn API
const attestationResponse = await startRegistration(options);
// Deep convert ArrayBuffers to Base64URL for JSON transport
const serializableResponse = JSON.parse(JSON.stringify(attestationResponse, (key, value) => {
if (value instanceof ArrayBuffer) {
return bufferToBase64URL(value);
}
return value;
}));
// 3. Send the attestation response back to the server for verification
const respVerification = await fetch('/api/auth/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: state.username,
response: serializableResponse
}),
});
const verificationJSON = await respVerification.json();
if (verificationJSON.verified) {
state.successMessage = `Successfully registered ${state.username}! You can now log in.`;
state.mode = 'login';
} else {
throw new Error('Verification failed: ' + (verificationJSON.error || 'Unknown error'));
}
} catch (err: any) {
console.error('Registration failed:', err);
// This is crucial: handle user cancellation gracefully
if (err.name === 'NotAllowedError') {
state.error = 'Registration was cancelled.';
} else {
state.error = err.message || 'An unexpected error occurred.';
}
} finally {
state.isLoading = false;
}
});
const handleLogin = $(async () => {
state.isLoading = true;
state.error = null;
state.successMessage = null;
try {
// 1. Get authentication options from the server
const respOptions = await fetch('/api/auth/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: state.username }),
});
if (!respOptions.ok) {
const errorData = await respOptions.json();
throw new Error(errorData.message || 'Failed to get login options');
}
let options = await respOptions.json();
options = deepBase64URLToBuffer(options);
// 2. Prompt user for authentication
const assertionResponse = await startAuthentication(options);
const serializableResponse = JSON.parse(JSON.stringify(assertionResponse, (key, value) => {
if (value instanceof ArrayBuffer) {
return bufferToBase64URL(value);
}
return value;
}));
// 3. Send the assertion response back to the server for verification
const respVerification = await fetch('/api/auth/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: state.username,
response: serializableResponse
}),
});
const verificationJSON = await respVerification.json();
if (verificationJSON.verified) {
state.successMessage = `Welcome back, ${state.username}!`;
} else {
throw new Error('Verification failed: ' + (verificationJSON.error || 'Unknown error'));
}
} catch (err: any) {
console.error('Login failed:', err);
if (err.name === 'NotAllowedError') {
state.error = 'Authentication was cancelled.';
} else {
state.error = err.message || 'An unexpected error occurred during login.';
}
} finally {
state.isLoading = false;
}
});
return (
<div class="auth-container">
{/* ... JSX for the form ... */}
</div>
);
});
To visualize the entire registration flow, a sequence diagram is invaluable. It clarifies the interaction between the client, the browser’s WebAuthn API, and our server.
sequenceDiagram participant C as Client (Qwik Component) participant B as Browser (WebAuthn API) participant S as Server (Qwik City Endpoint) C->>S: POST /api/auth/register/start (username) S->>S: generateRegistrationOptions() S-->>C: 200 OK (challenge options) C->>C: deepBase64URLToBuffer(options) C->>B: navigator.credentials.create(options) B-->>B: User interacts (e.g., Touch ID) B-->>C: Promise resolves with attestationResponse C->>C: Serialize response (ArrayBuffer to Base64URL) C->>S: POST /api/auth/register/finish (response) S->>S: verifyRegistrationResponse() S-->>C: 200 OK ({ verified: true }) C->>C: Update UI (state.successMessage)
The next critical phase was testing. How do you test a component that relies on a browser-native API that doesn’t exist in Vitest’s JSDOM environment? The answer is extensive mocking. We used Vitest’s vi.stubGlobal
to inject a mock implementation of navigator.credentials
. We also used msw
(Mock Service Worker) to intercept fetch
calls, allowing us to simulate server responses without a running server.
This approach lets us test the component’s logic in isolation. We can verify that it correctly calls the server, processes the challenge, invokes the WebAuthn API with the right parameters, and handles both success and failure states gracefully.
Here is a representative test file for the registration flow. The setup is complex, but it provides immense confidence in the component’s behavior.
// src/components/auth/webauthn-form.spec.tsx
import { createDOM } from '@builder.io/qwik/testing';
import { test, describe, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebAuthnForm } from './webauthn-form';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { bufferToBase64URL, base64URLToBuffer } from '~/lib/auth/base64url';
// Mock server responses
const mockRegistrationOptions = {
challenge: bufferToBase64URL(new Uint8Array([1, 2, 3]).buffer),
rp: { name: 'Qwik WebAuthn Demo', id: 'localhost' },
user: { id: 'user123', name: 'testuser', displayName: 'testuser' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
authenticatorSelection: { userVerification: 'preferred' },
timeout: 60000,
attestation: 'none',
};
const mockAttestationResponse = {
id: 'mockCredentialId',
rawId: bufferToBase64URL(new Uint8Array([4, 5, 6]).buffer),
response: {
clientDataJSON: bufferToBase64URL(new TextEncoder().encode('{"type":"webauthn.create","challenge":"AQID"}').buffer),
attestationObject: bufferToBase64URL(new Uint8Array([7, 8, 9]).buffer),
},
type: 'public-key',
};
const server = setupServer(
rest.post('/api/auth/register/start', (req, res, ctx) => {
return res(ctx.json(mockRegistrationOptions));
}),
rest.post('/api/auth/register/finish', (req, res, ctx) => {
return res(ctx.json({ verified: true }));
})
);
// Mock the WebAuthn browser API
const mockCredentials = {
create: vi.fn(),
get: vi.fn(),
};
describe('WebAuthnForm Component', () => {
beforeEach(() => {
server.listen({ onUnhandledRequest: 'error' });
vi.stubGlobal('navigator', { credentials: mockCredentials });
vi.stubGlobal('fetch', vi.fn(fetch)); // Make sure msw intercepts it
});
afterEach(() => {
server.resetHandlers();
server.close();
vi.restoreAllMocks();
});
test('should handle successful registration flow', async () => {
mockCredentials.create.mockResolvedValueOnce(JSON.parse(JSON.stringify({
...mockAttestationResponse,
rawId: base64URLToBuffer(mockAttestationResponse.rawId),
response: {
clientDataJSON: base64URLToBuffer(mockAttestationResponse.response.clientDataJSON),
attestationObject: base64URLToBuffer(mockAttestationResponse.response.attestationObject),
}
})));
const { screen, render, userEvent } = await createDOM();
await render(<WebAuthnForm />);
const usernameInput = screen.querySelector('input[name="username"]') as HTMLInputElement;
const registerButton = screen.querySelector('button[data-testid="register-btn"]') as HTMLButtonElement;
await userEvent(usernameInput, 'input', { target: { value: 'testuser' } });
expect(usernameInput.value).toBe('testuser');
await userEvent(registerButton, 'click');
// Verify it called the server to get options
// MSW will throw an error if the endpoint isn't called, which is an implicit assertion
// Verify it called the WebAuthn API
expect(mockCredentials.create).toHaveBeenCalledOnce();
const createCallArgs = mockCredentials.create.mock.calls[0][0];
expect(createCallArgs.challenge).toBeInstanceOf(ArrayBuffer);
// Check for success message after flow completes
const successMessage = await screen.querySelector('.success-message');
expect(successMessage?.textContent).toContain('Successfully registered testuser!');
});
test('should handle registration cancellation', async () => {
// Simulate the user cancelling the browser prompt
mockCredentials.create.mockRejectedValueOnce(new DOMException('The operation was aborted.', 'NotAllowedError'));
const { screen, render, userEvent } = await createDOM();
await render(<WebAuthnForm />);
const usernameInput = screen.querySelector('input[name="username"]') as HTMLInputElement;
const registerButton = screen.querySelector('button[data-testid="register-btn"]') as HTMLButtonElement;
await userEvent(usernameInput, 'input', { target: { value: 'testuser' } });
await userEvent(registerButton, 'click');
const errorMessage = await screen.querySelector('.error-message');
expect(errorMessage?.textContent).toBe('Registration was cancelled.');
});
});
This journey demonstrated that combining Qwik and WebAuthn is not only feasible but incredibly powerful. The performance benefits of Qwik’s resumability on a critical entry point like a login page are substantial. The UX and security gains from passwordless authentication are transformative. The key was to respect each technology’s paradigm: using server-side logic for the cryptographic heavy lifting and embracing Qwik’s event-driven, resumable model on the client to orchestrate the asynchronous browser APIs. The resulting component is lightweight, fast, secure, and robustly tested.
The current solution, while functional, uses an in-memory store on the server, making it unsuitable for production without a backing database. The client-side error handling is also basic; a production implementation should map specific WebAuthn error codes to user-friendly messages. Future iterations could explore Conditional UI (passkey autofill), which requires the mediation: 'conditional'
flag and a different component lifecycle via useVisibleTask$
. Furthermore, while Vitest and JSDOM provide excellent unit and integration testing capabilities, true confidence would come from supplementing this with end-to-end tests using a tool like Playwright, which can automate a real browser and its native WebAuthn prompts.