How to Test Passkeys and WebAuthn in Playwright
Short answer
Passkeys cannot be clicked like passwords—WebAuthn requires platform authenticators or CDP virtual authenticators in Chromium. Playwright exposes context.addInitScript plus Chrome DevTools Protocol WebAuthn commands to register and assert credentials. Test registration, login, fallback to password, and probe Assert on session—not only that the passkey button rendered.
Part of Testing Guides by auth and identity.
Who this is for
Teams shipping passkey / WebAuthn login (FIDO2, platform authenticators, security keys) on web who need Playwright E2E in headless Chromium CI—without physical YubiKeys on every runner.
Typical stacks: Custom WebAuthn via @simplewebauthn/browser, Auth0 passkeys, Firebase passkeys, Clerk Web3/passkeys beta, or enterprise passwordless rollouts alongside MFA.
Why passkey testing matters
Passkey bugs strand users:
- Registration succeeds, login fails — credential ID mismatch across subdomains
- No fallback — WebAuthn unavailable; user locked out in older browsers
- Replay / clone — same credential on two accounts (should be rejected server-side)
- Conditional UI — autofill path untested vs button path
- CI blindness — tests skip WebAuthn entirely; prod breaks on first passkey user
E2E must exercise navigator.credentials.create and get paths with virtual authenticators—not mock WebAuthn in unit tests only.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| No authenticator | Default Chromium | NotSupportedError | CDP WebAuthn.enable + virtual |
| Registration | Resident key required | create() fails | hasResidentKey: true in virtual |
| User verification | required vs preferred | UV flag mismatch | Match server policy in virtual |
| RP ID | app.test vs www | Login fails | Align PLAYWRIGHT_BASE_URL hostname |
| Cross-origin | iframes | Blocked WebAuthn | Top-level only in E2E |
| Password fallback | Passkey fails | No alternate path | Negative + fallback spec |
| 2FA + passkey | Stacked factors | Complex ceremony | Split scenarios |
| Safari / Firefox | No CDP virtual | Chromium-only CI | Document browser matrix |
| Credential delete | Re-register same user | Duplicate creds | Probe credential count |
| Attestation | Enterprise attestation | CI rejects | isUserVerified + format flags |
| Conditional mediation | Autofill UI | Flaky | autocomplete=username webauthn spec |
| Timeout | User absent | Hang | Set authenticator timeout |
| Backup codes | Lost device | Untested recovery | Separate recovery guide |
| Platform vs cross-platform | authenticatorAttachment | Wrong attachment | Virtual authenticatorAttachment |
| Parallel tests | Shared virtual creds | Collision | New context per test |
Playwright WebAuthn (official pattern)
Playwright documents virtual authenticator setup via CDP:
// tests/fixtures/webauthn.ts
import { test as base, chromium } from '@playwright/test';
type WebAuthnFixtures = {
webAuthnContext: import('@playwright/test').BrowserContext;
};
export const test = base.extend<WebAuthnFixtures>({
webAuthnContext: async ({}, use) => {
const browser = await chromium.launch();
const context = await browser.newContext();
const cdp = await context.newCDPSession(await context.newPage());
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
await use(context);
await browser.close();
},
});
See Playwright WebAuthn for the latest API—prefer official helpers when available on your Playwright version.
Registration flow
// @Scenario: auth/passkey-register
import { test, expect } from '../fixtures/webauthn';
test('user registers passkey and session is valid', async ({ webAuthnContext, request, runId }) => {
const page = await webAuthnContext.newPage();
await request.post('/api/test/seed-user', { data: { runId, email: `pk+${runId}@test.local` } });
await page.goto('/login');
await page.getByLabel('Email').fill(`pk+${runId}@test.local`);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Create passkey' }).click();
// WebAuthn prompt handled by virtual authenticator automatically
await expect(page.getByText(/passkey created/i)).toBeVisible({ timeout: 15_000 });
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-webauthn-credentials?runId=${runId}`);
return (await res.json()).count;
}).toBe(1);
await expect.poll(async () => {
const res = await page.request.get('/api/test/probe-session');
return (await res.json()).authenticated;
}).toBe(true);
});
Login with existing credential
Seed credential via API in Arrange when registration is not the SUT:
await request.post('/api/test/seed-webauthn-credential', {
data: { runId, userId, credentialId: `cred-${runId}` },
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
await expect.poll(() => probeSession(page)).toBe(true);
Server-side seed must create valid credential records matching your WebAuthn verification library (SimpleWebAuthn, etc.).
Fallback when WebAuthn unavailable
test('password fallback when passkey not supported', async ({ browser, runId }) => {
// Context WITHOUT virtual authenticator
const context = await browser.newContext();
const page = await context.newPage();
await page.addInitScript(() => {
// Optional: force isUserVerifyingPlatformAuthenticatorAvailable false
});
await page.goto('/login');
await page.getByRole('button', { name: 'Use password instead' }).click();
await page.getByLabel('Password').fill(`pw-${runId}`);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect.poll(() => probeSession(page)).toBe(true);
await context.close();
});
CDP credential inspection (debug)
const client = await page.context().newCDPSession(page);
const { credentials } = await client.send('WebAuthn.getCredentials', {
authenticatorId,
});
expect(credentials.length).toBeGreaterThan(0);
Use sparingly—probe session remains authoritative for app state.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Skip WebAuthn in CI | Prod-only failures | Virtual authenticator |
Mock navigator.credentials | Server verification untested | CDP virtual + real API |
| Physical key on one laptop | Not parallelizable | Virtual authenticator |
| Assert button visible only | Ceremony never runs | Complete register/login |
| Wrong RP ID in test env | Works locally only | Match hostname to prod pattern |
| Firefox-only job without doc | False confidence | Chromium CI + manual matrix |
External references
- Playwright WebAuthn
- Chrome DevTools Protocol WebAuthn
- WebAuthn spec (W3C)
- SimpleWebAuthn docs
- MDN Web Authentication API
- FIDO Alliance
Example scenario
Situation: User registers passkey on staging subdomain; production RP ID differs—login ceremony fails silently.
Expected outcome: Registration and login succeed when RP ID matches deployed hostname; session probe authenticated.
Why UI-only automation breaks: Create passkey button shows success toast while credential row missing in DB.
- Arrange: Virtual authenticator enabled; seed user with runId; RP ID aligned to PLAYWRIGHT_BASE_URL host.
- Act: Click Create passkey; complete WebAuthn ceremony via virtual authenticator.
- Assert: Probe webauthn_credentials count=1; probe session authenticated.
TestChimp workflow: // @Scenario: auth/passkey-register; document Firefox/Safari manual matrix outside Chromium CI.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
Frequently asked questions
Does Playwright support passkeys in headless CI?
Yes in Chromium via CDP WebAuthn virtual authenticators—see Playwright WebAuthn docs. Firefox and Safari need separate manual or vendor-specific strategies.
How do I test passkey registration vs login?
Registration spec runs create() ceremony with empty virtual authenticator. Login spec seeds credential via API or reuses prior registration—assert probe session both times.
What RP ID should tests use?
Match your app hostname (e.g. app.test in CI). RP ID mismatch is the most common passkey works-locally-only bug.
Can I test security keys (cross-platform) vs platform passkeys?
Virtual authenticator options include transport internal vs usb-nfc-bluetooth. Set authenticatorAttachment to match product policy.
How do I test password fallback?
Use browser context without virtual authenticator or simulate unsupported WebAuthn; assert fallback path reaches probe-authenticated session.
Should I mock navigator.credentials?
Avoid for E2E—it bypasses server verification. CDP virtual authenticator exercises the real client ceremony.
Passkeys plus MFA—how to structure tests?
Split scenarios: passkey-only login, MFA-only, and combined if product requires. Probe session after each factor completes.
TestChimp with passkey flows?
/testchimp init adds session and credential probes; /testchimp test maintains WebAuthn specs when auth UI changes—virtual authenticator setup stays in fixtures.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.