Passkey Implementation Testing: End-to-End Quality for Passwordless Auth
Passkeys are the consumer-friendly implementation of FIDO2/WebAuthn, introduced by Apple, Google, and Microsoft as a coordinated standard for passwordless authentication. Unlike hardware security keys, passkeys sync across devices via iCloud Keychain, Google Password Manager, and Microsoft's ecosystem — which introduces testing challenges around cross-device scenarios, OS-level dialogs, and sync behavior. This guide covers passkey-specific testing from implementation validation to production monitoring.
What Makes Passkey Testing Different from WebAuthn Testing
Passkeys are a subset of WebAuthn with specific constraints:
- Discoverable credentials are required — passkeys are always resident keys
- User verification is required — biometric or PIN is always performed
- Cross-device sync — a passkey created on iPhone may be used on Mac
- Conditional UI — passkeys surface in browser autofill, not just explicit triggers
- Hybrid transport — cross-device authentication uses QR code + Bluetooth
Testing must cover the full passkey user journey, including the UI entry points that differ from security key flows.
Setting Up Passkey Tests in Playwright
// test-helpers/passkey.js
const { chromium } = require('@playwright/test');
async function createPasskeyAuthenticator(page) {
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
// Passkeys require: resident key, user verification, internal transport
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
ctap2Version: 'ctap2_1',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
return { client, authenticatorId };
}
module.exports = { createPasskeyAuthenticator };Testing the Passkey Registration Flow
Standard Registration
import { test, expect } from '@playwright/test';
import { createPasskeyAuthenticator } from '../test-helpers/passkey';
test('passkey registration creates discoverable credential', async ({ page }) => {
const { client, authenticatorId } = await createPasskeyAuthenticator(page);
await page.goto('/register');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="display-name"]', 'Test User');
await page.click('[data-testid="create-passkey-btn"]');
// Verify success UI
await expect(page.locator('[data-testid="passkey-created"]')).toBeVisible({
timeout: 10000,
});
// Verify credential is stored in virtual authenticator
const { credentials } = await client.send('WebAuthn.getCredentials', {
authenticatorId,
});
expect(credentials).toHaveLength(1);
expect(credentials[0].isResidentCredential).toBe(true);
});Testing Registration Exclusion
WebAuthn allows servers to exclude existing credentials to prevent duplicate registrations:
test('registration excludes existing passkeys', async ({ page }) => {
const { client, authenticatorId } = await createPasskeyAuthenticator(page);
// Register first passkey
await page.goto('/register');
await page.fill('[name="email"]', 'test@example.com');
await page.click('[data-testid="create-passkey-btn"]');
await expect(page.locator('[data-testid="passkey-created"]')).toBeVisible();
// Try to add another passkey for same account
await page.goto('/settings/security');
await page.click('[data-testid="add-another-passkey"]');
// Should either create a new one or warn about existing credential
const result = await Promise.race([
page.locator('[data-testid="passkey-added"]').waitFor({ timeout: 5000 }),
page.locator('[data-testid="credential-excluded-error"]').waitFor({ timeout: 5000 }),
]);
// Either outcome is acceptable; test documents what happens
expect(result).toBeTruthy();
});Testing Conditional UI (Autofill Integration)
Passkeys can surface in the browser's autofill dropdown on login forms. This requires mediation: conditional in the WebAuthn call:
test('passkey appears in autofill suggestions', async ({ page }) => {
const { client } = await createPasskeyAuthenticator(page);
// First register a passkey
await registerTestPasskey(page, 'user@example.com');
// Navigate to login page
await page.goto('/login');
// Focus the email field to trigger autofill
await page.click('[name="email"]');
// In production, passkey would appear in autofill dropdown
// In tests, we verify the page has set up conditional mediation
const hasConditionalMediation = await page.evaluate(() => {
return window.__webauthnConditionalMediationActive === true;
});
expect(hasConditionalMediation).toBe(true);
});Note: True conditional UI rendering requires OS-level autofill that can't be fully automated. Test that the API call is configured correctly instead.
Testing the Authentication Flow
test('complete passkey authentication flow', async ({ page }) => {
const { client } = await createPasskeyAuthenticator(page);
// Setup: register passkey
await registerTestPasskey(page, 'user@example.com');
await page.click('[data-testid="logout"]');
// Authenticate with passkey
await page.goto('/login');
await page.click('[data-testid="use-passkey-btn"]');
// Virtual authenticator auto-presents the discoverable credential
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible({
timeout: 10000,
});
// Verify session established correctly
const sessionUser = await page.evaluate(() => window.__currentUser);
expect(sessionUser.email).toBe('user@example.com');
});Testing Cross-Device Passkey Scenarios
True cross-device passkey sync can't be automated, but you can test the QR code / hybrid flow initiation:
test('hybrid transport option is presented on incompatible device', async ({ page }) => {
// Simulate a device with no passkeys (e.g., signing in on new browser)
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
// No credentials registered on this device
},
});
await page.goto('/login');
await page.click('[data-testid="use-passkey-btn"]');
// Should offer cross-device option
await expect(page.locator('[data-testid="use-phone-passkey"]')).toBeVisible();
// Or show the browser's native "Use a phone or tablet" prompt
});Testing Passkey Management UI
Users need to manage their passkeys — name them, delete them, see when they were last used.
test('passkey management operations', async ({ page }) => {
const { client } = await createPasskeyAuthenticator(page);
await registerTestPasskey(page, 'user@example.com');
await page.goto('/settings/security/passkeys');
// Verify passkey is listed
await expect(page.locator('[data-testid="passkey-item"]')).toBeVisible();
// Rename passkey
await page.click('[data-testid="passkey-rename-btn"]');
await page.fill('[data-testid="passkey-name-input"]', 'My MacBook');
await page.click('[data-testid="save-name-btn"]');
await expect(page.locator('[data-testid="passkey-item"]')).toContainText('My MacBook');
// Delete passkey
await page.click('[data-testid="passkey-delete-btn"]');
await page.click('[data-testid="confirm-delete-btn"]');
await expect(page.locator('[data-testid="passkey-item"]')).not.toBeVisible();
// Verify credential removed from virtual authenticator
const { credentials } = await client.send('WebAuthn.getCredentials', {
authenticatorId: client.authenticatorId,
});
expect(credentials).toHaveLength(0);
});Testing Fallback Flows
Always test what happens when passkeys aren't available:
test('graceful fallback when WebAuthn not supported', async ({ page }) => {
// Override WebAuthn API to simulate unsupported browser
await page.addInitScript(() => {
Object.defineProperty(navigator, 'credentials', {
value: undefined,
writable: true,
});
delete window.PublicKeyCredential;
});
await page.goto('/login');
// Passkey option should not be shown
await expect(page.locator('[data-testid="use-passkey-btn"]')).not.toBeVisible();
// Password login should be available
await expect(page.locator('[name="password"]')).toBeVisible();
});
test('fallback when passkey ceremony fails', async ({ page }) => {
const { client } = await createPasskeyAuthenticator(page, {
isUserVerified: false, // Simulate biometric failure
});
await page.goto('/login');
await page.click('[data-testid="use-passkey-btn"]');
// Should offer alternative login method, not leave user stuck
await expect(page.locator('[data-testid="use-password-instead"]')).toBeVisible({
timeout: 10000,
});
});Server-Side Passkey Validation Tests
const { verifyAuthenticationResponse } = require('@simplewebauthn/server');
describe('Passkey server validation', () => {
it('validates passkey assertion with correct counter', async () => {
const verification = await verifyAuthenticationResponse({
response: mockAssertionResponse,
expectedChallenge: testChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
authenticator: {
credentialPublicKey: storedPublicKey,
credentialID: storedCredentialID,
counter: 0,
},
});
expect(verification.verified).toBe(true);
expect(verification.authenticationInfo.newCounter).toBeGreaterThan(0);
});
it('rejects assertion when user verification flag not set', async () => {
// Passkeys require UV=true in flags
const responseWithoutUV = { ...mockAssertionResponse };
responseWithoutUV.response.authenticatorData = buildAuthData({ uv: false });
await expect(
verifyAuthenticationResponse({
...verifyOptions,
response: responseWithoutUV,
requireUserVerification: true,
})
).rejects.toThrow(/user verification/i);
});
});Monitoring Passkey Flows with HelpMeTest
Passkey authentication is critical path — a break means users can't log in. HelpMeTest can monitor this flow every 5 minutes:
As new test user
Register a passkey
Log out
Authenticate with passkey
Verify dashboard loads
Verify user session is correctIf the passkey flow breaks — due to a server config change, certificate rotation, or origin mismatch — you'll know within 5 minutes, not hours.
Summary
Testing passkey implementations requires: virtual authenticators configured with resident keys and UV enabled, registration tests verifying discoverable credential creation, authentication tests covering the full ceremony, management UI tests for rename/delete operations, fallback tests for WebAuthn-unavailable scenarios, server-side tests for assertion validation and UV flag requirements, and continuous monitoring of the authentication flow in production. The most commonly missed test is the fallback — always ensure users can still authenticate when passkeys fail.