WebAuthn Passkeys — Test Without Hardware Keys
Playwright 1.61’s browserContext.credentials API lets you seed passkeys into a virtual authenticator. Test WebAuthn login flows in CI without buying a YubiKey for every runner.
📖 The Story: The YubiKey Bottleneck
A fintech company rolled out passkey-based login. The QA team needed to test the full login flow in CI. The problem: WebAuthn requires a hardware authenticator (a YubiKey or similar), and CI runners don’t have USB ports.
The team tried mocking navigator.credentials entirely with page.addInitScript, but the mock was incomplete — it didn’t handle the WebAuthn ceremony correctly, and the backend rejected the fake assertions because the signature didn’t match a real credential.
They tried buying 4 YubiKeys and plugging them into a dedicated Mac mini that ran tests via a physical USB hub. It worked, but only one test could run at a time (the YubiKey can’t handle concurrent ceremonies), and the Mac mini was a single point of failure. The team spent more time maintaining the YubiKey rig than writing tests.
The virtual authenticator in Playwright 1.61 eliminates the hardware. Tests seed a passkey, install it, and the browser answers WebAuthn ceremonies automatically.
🎯 Why It Matters
No Hardware
Seed passkeys in software. No YubiKeys, no USB hubs, no dedicated Mac mini.
All Browsers
Works on Chromium, Firefox, and WebKit. The virtual authenticator is browser-agnostic.
Persistable
Passkeys can be saved to storageState and re-seeded into later contexts. One setup test, many consumers.
Parallel
Each context gets its own authenticator. Run 10 WebAuthn tests in parallel without contention.
🌍 Real World Example
A B2B SaaS company offers passkey login for enterprise customers. Their test suite needs to verify: (1) a new user can register a passkey, (2) a returning user can log in with a passkey, (3) a user can register multiple passkeys, and (4) a compromised passkey can be revoked. Before 1.61, only test (1) was possible (by mocking the registration ceremony), and tests (2)–(4) were manual. With browserContext.credentials, all four are automated and run in parallel across 3 browsers.
🧒 Explain Like I'm 10
A passkey is like a special key that lives inside your phone or computer. When you log in, the website asks your phone “hey, is this really you?” and your phone proves it is by signing a secret message.
Before, testing this was hard because you needed a real phone or a real hardware key. Playwright 1.61 gives you a fake phone that lives inside the browser. You tell it “here’s a key for user@example.com”, and whenever the website asks “is this really you?”, the fake phone answers yes.
No real hardware, no USB cables, no phones on the test desk.
🎓 Professional Explanation
WebAuthn (Web Authentication) is the W3C standard behind passkeys. A passkey is a public-key credential: the private key lives on the user’s device (the authenticator), and the public key is registered with the relying party (the website). During login, the site sends a challenge, the authenticator signs it with the private key, and the site verifies the signature with the public key.
Testing WebAuthn in CI has always been hard because the private key must live on a real hardware authenticator. Playwright 1.61 introduces browserContext.credentials, a virtual authenticator that lets tests seed passkeys (private key + public key + credential ID + user handle) into a context. The browser’s navigator.credentials.create() and navigator.credentials.get() calls are then answered automatically using the seeded credentials — no hardware required, works on all browsers.
📊 The Flow
The WebAuthn Virtual Authenticator Flow
⚙️ The browserContext.credentials API
The API lives on browserContext.credentials and exposes four methods: create() (seed a passkey), install() (activate the virtual authenticator), get() (read back a seeded passkey for persistence), and removal methods. The passkey is scoped to the context — it is not shared across contexts automatically.
import { test } from class="tk-str">'@playwright/test'; test(class="tk-str">'passkey login', async ({ browser }) => { const context = await browser.newContext(); class=class="tk-str">"tk-com">// Seed a passkey your backend provisioned for a test user. await context.credentials.create(class="tk-str">'example.com', { id: credentialId, class=class="tk-str">"tk-com">// Buffer or base64url string userHandle, class=class="tk-str">"tk-com">// the user's unique ID privateKey, class=class="tk-str">"tk-com">// PKCS#8 private key publicKey, class=class="tk-str">"tk-com">// SPKI public key }); class=class="tk-str">"tk-com">// Activate the virtual authenticator. await context.credentials.install(); const page = await context.newPage(); await page.goto(class="tk-str">'https:class="tk-com">//example.com/login'); class=class="tk-str">"tk-com">// The page's navigator.credentials.get() is answered with the seeded passkey. await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Sign in with passkey' }).click(); await expect(page).toHaveURL(/.*dashboard/); });
create() seeds the passkey. install() activates the virtual authenticator so the browser answers WebAuthn ceremonies. The four fields (id, userHandle, privateKey, publicKey) come from your backend’s test-user provisioning — typically a setup script that calls your WebAuthn library to generate a keypair.
class=class="tk-str">"tk-com">// Persist a passkey across tests via storageState test(class="tk-str">'register a passkey, then reuse it', async ({ browser }) => { class=class="tk-str">"tk-com">// Test 1: register a passkey via the real registration flow const context1 = await browser.newContext(); await context1.credentials.install(); class=class="tk-str">"tk-com">// activate before registration const page1 = await context1.newPage(); await page1.goto(class="tk-str">'https:class="tk-com">//example.com/register-passkey'); await page1.getByRole(class="tk-str">'button', { name: class="tk-str">'Add passkey' }).click(); class=class="tk-str">"tk-com">// The registration ceremony creates a passkey in the virtual authenticator. class=class="tk-str">"tk-com">// Read it back for persistence. const passkey = await context1.credentials.get(class="tk-str">'example.com'); class=class="tk-str">"tk-com">// Save to storageState (1.61+ includes credentials) await context1.storageState({ path: class="tk-str">'passkey-state.json' }); class=class="tk-str">"tk-com">// Test 2: re-seed the passkey into a fresh context const context2 = await browser.newContext({ storageState: class="tk-str">'passkey-state.json' }); const page2 = await context2.newPage(); await page2.goto(class="tk-str">'https:class="tk-com">//example.com/login'); await page2.getByRole(class="tk-str">'button', { name: class="tk-str">'Sign in with passkey' }).click(); await expect(page2).toHaveURL(/.*dashboard/); });
storageState in 1.61+ includes virtual WebAuthn credentials via the new credentials option. This lets you register a passkey once in a setup test, save the state, and re-seed it into every test that needs an authenticated session — the same pattern you already use for cookies and localStorage.
class=class="tk-str">"tk-com">// Test multiple passkeys for the same user test(class="tk-str">'user has multiple passkeys', async ({ browser }) => { const context = await browser.newContext(); class=class="tk-str">"tk-com">// Seed two passkeys for the same user await context.credentials.create(class="tk-str">'example.com', { id: credentialId1, userHandle, privateKey: key1, publicKey: pub1, }); await context.credentials.create(class="tk-str">'example.com', { id: credentialId2, userHandle, privateKey: key2, publicKey: pub2, }); await context.credentials.install(); class=class="tk-str">"tk-com">// The browser will answer with whichever passkey matches the challenge. const page = await context.newPage(); await page.goto(class="tk-str">'https:class="tk-com">//example.com/login'); await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Sign in with passkey' }).click(); await expect(page).toHaveURL(/.*dashboard/); });
You can seed multiple passkeys for the same relying party. The virtual authenticator will answer with whichever credential matches the challenge’s allowCredentials list.
🔄 Before vs After
Before: Mocking navigator.credentials (1.60 and earlier)
class=class="tk-str">"tk-com">// Brittle, incomplete, backend rejects the fake signature await page.addInitScript(() => { const original = navigator.credentials; navigator.credentials = { ...original, get: async () => ({ id: class="tk-str">'fake-credential-id', type: class="tk-str">'public-key', rawId: new ArrayBuffer(16), response: { authenticatorData: new ArrayBuffer(37), clientDataJSON: new ArrayBuffer(0), signature: new ArrayBuffer(64), class=class="tk-str">"tk-com">// INVALID — backend rejects userHandle: new ArrayBuffer(8), }, }), }; });
The mock returns a credential with a fake signature. Real backends verify the signature against the registered public key, so the fake is rejected and login fails. The only way to make it work was to disable signature verification on the backend — a security hole nobody wanted.
After: browserContext.credentials (1.61+)
import { test } from class="tk-str">'@playwright/test'; test(class="tk-str">'passkey login', async ({ browser }) => { const context = await browser.newContext(); await context.credentials.create(class="tk-str">'example.com', { id: credentialId, userHandle, privateKey, class=class="tk-str">"tk-com">// real PKCS#8 key — signature is valid publicKey, }); await context.credentials.install(); const page = await context.newPage(); await page.goto(class="tk-str">'https:class="tk-com">//example.com/login'); await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Sign in with passkey' }).click(); await expect(page).toHaveURL(/.*dashboard/); });
The virtual authenticator signs the challenge with the real private key. The backend verifies the signature against the registered public key — login succeeds without any backend hacks. Works on Chromium, Firefox, and WebKit.
Passkey seeds are per-context. They are not shared across contexts automatically. Either persist the seeded credential to storageState (which now includes WebAuthn credentials via the credentials option) or read it back with credentials.get() and re-seed it into the next context. Each parallel test worker gets its own context, so there’s no contention — but you must re-seed per context.
⚠️ Common Mistakes
Mistake 1: Calling install() before create()
You must call credentials.create() first to seed the passkey, then credentials.install() to activate the authenticator. If you install first, the authenticator is active but empty — WebAuthn ceremonies will fail with ‘no matching credential’.
Mistake 2: Expecting passkeys to persist across contexts
Passkeys live on the context that created them. A fresh browser.newContext() starts with no passkeys. Either save to storageState and load it, or re-seed with credentials.create() in every test.
Mistake 3: Using the wrong key format
The privateKey must be PKCS#8 format and the publicKey must be SPKI format. If your backend generates keys in a different format (JWK, PEM-wrapped, raw), convert them before seeding. Most WebAuthn libraries can export to PKCS#8/SPKI.
✅ Best Practices
- Seed passkeys in a setup test and persist via
storageState— don’t re-provision in every test. - Always call
create()beforeinstall()— the authenticator must have credentials before it’s activated. - Use PKCS#8 for privateKey and SPKI for publicKey — these are the formats Playwright expects.
- Use unique credential IDs per test user to avoid collisions when seeding multiple passkeys.
- Pair with the WebStorage API (also new in 1.61) to set any session tokens the backend returns after passkey login.
🏗️ Senior Engineer Deep Dive
How the virtual authenticator signs challenges
When the page calls navigator.credentials.get(), the browser sends a challenge (a random buffer) to the authenticator. The virtual authenticator looks up the seeded private key for the requesting origin, signs the challenge with that key, and returns the signature. The backend verifies the signature against the registered public key. Because the signing uses a real cryptographic key (not a mock), the backend’s verification succeeds — no security shortcuts needed.
When to seed vs when to register via the UI
Two patterns: (1) Seed — your backend provisions a test-user passkey out-of-band, you seed it into the context, and the test starts already “logged in”. Fast, no UI interaction. (2) Register via UI — the test navigates to the registration page, clicks “Add passkey”, and the virtual authenticator answers the registration ceremony. The passkey is created in-context and can be saved to storageState. Use seeding for speed (login tests), use UI registration for coverage (registration flow tests).
💼 Interview Questions
Show Answer
Show Answer
Show Answer
Show Answer
🏋️ Practical Exercise
Seed Your First Passkey
- Install Playwright 1.61+:
npm install -D @playwright/test@1.62.1 - Generate a test keypair using your WebAuthn library (or use @github/webauthn-json)
- Write a test that creates a context, seeds the passkey with
credentials.create(), and installs it - Navigate to a WebAuthn demo site like
https://webauthn.io - Click “Login with passkey” — the virtual authenticator should answer automatically
- Assert the user is logged in
🚀 Mini Project
🏗️ The Passkey StorageState Pattern
- Write a setup test (in a separate project with
dependencies) that registers a passkey via the UI - Save the context state with
storageState({ path: 'passkey.json' }) - In your main test project, configure
use: { storageState: 'passkey.json' } - Write 3 tests that each start with the user already passkey-authenticated
- Run them in parallel — each gets its own context with the seeded passkey
- Verify no test interferes with another
📝 Quiz
Test your understanding. Click an option to check your answer.
❓ FAQ
Q: Can I use the virtual authenticator for conditional UI (passkey autofill)?
A: Yes. The virtual authenticator answers navigator.credentials.get({ mediation: 'conditional' }) just like a real authenticator. If your login form has conditional UI enabled, the seeded passkey will appear in the browser’s autofill dropdown.
Q: Does the virtual authenticator support user verification (PIN/biometric)?
A: Yes. You can configure the authenticator to require user verification, and the virtual authenticator will report uv: true in the assertion. The actual “verification” is automatic — there’s no PIN prompt in the test.
Q: Can I test passkey revocation?
A: Yes. Seed a passkey, log in, then call your backend’s revoke endpoint. The next login attempt should fail because the credential is no longer valid. The virtual authenticator still has the passkey, but the backend rejects the signature.
📦 Summary
🎯 Key Takeaways
- browserContext.credentials exposes a virtual WebAuthn authenticator — no hardware needed.
- Always create() before install() — the authenticator must have credentials before activation.
- Passkeys are per-context — persist via storageState (1.61+ includes credentials) or re-seed.
- Use PKCS#8 for privateKey, SPKI for publicKey — convert from other formats before seeding.
- Works on all three browsers — Chromium, Firefox, and WebKit, in parallel without contention.
Comments
Comments
Post a Comment