📖 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

1. Backend provisions a passkey (private key, public key, credential ID) for a test user
⬇️
2. Test calls context.credentials.create('example.com', { privateKey, publicKey, id, userHandle })
⬇️
3. Test calls context.credentials.install() to activate the virtual authenticator
⬇️
4. Page calls navigator.credentials.get() → browser answers with seeded passkey
⬇️
5. Backend verifies signature → user is logged in

⚙️ 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.

typescript
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.

typescript
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.

typescript
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)

typescript
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+)

typescript
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.

⚠️ Gotcha

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

  1. Seed passkeys in a setup test and persist via storageState — don’t re-provision in every test.
  2. Always call create() before install() — the authenticator must have credentials before it’s activated.
  3. Use PKCS#8 for privateKey and SPKI for publicKey — these are the formats Playwright expects.
  4. Use unique credential IDs per test user to avoid collisions when seeding multiple passkeys.
  5. 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

Beginner
What does browserContext.credentials do?
Show Answer
It exposes a virtual WebAuthn authenticator. You can seed passkeys (private key, public key, credential ID, user handle) into a context, install the authenticator, and the browser will automatically answer navigator.credentials.get() and navigator.credentials.create() ceremonies using the seeded credentials. No hardware required.
Intermediate
Do passkeys persist across browser contexts?
Show Answer
No. Passkeys are scoped to the context that created them. To reuse across contexts, either save to storageState (which in 1.61+ includes WebAuthn credentials via the credentials option) and load it into the next context, or read the passkey back with credentials.get() and re-seed it with credentials.create().
Advanced
How does the virtual authenticator produce valid signatures that the backend accepts?
Show Answer
The virtual authenticator uses the real PKCS#8 private key you seeded to sign the WebAuthn challenge. The backend verifies the signature against the registered public key using standard WebAuthn crypto. Because the signing uses a real key (not a mock), verification succeeds without any backend security shortcuts.
Scenario
Your CI tests for passkey login fail because there’s no hardware authenticator. How do you fix this with Playwright 1.61?
Show Answer
Use browserContext.credentials. In a setup test, either seed a backend-provisioned passkey with credentials.create() + credentials.install(), or register a passkey via the UI with the virtual authenticator active. Save the state via storageState. In login tests, load the storageState — the passkey is available and navigator.credentials.get() is answered automatically. No hardware, no mocking, works on all browsers.

🏋️ Practical Exercise

🎯 Hands-On Practice hard

Seed Your First Passkey

  1. Install Playwright 1.61+: npm install -D @playwright/test@1.62.1
  2. Generate a test keypair using your WebAuthn library (or use @github/webauthn-json)
  3. Write a test that creates a context, seeds the passkey with credentials.create(), and installs it
  4. Navigate to a WebAuthn demo site like https://webauthn.io
  5. Click “Login with passkey” — the virtual authenticator should answer automatically
  6. Assert the user is logged in

🚀 Mini Project

🏗️ The Passkey StorageState Pattern

  1. Write a setup test (in a separate project with dependencies) that registers a passkey via the UI
  2. Save the context state with storageState({ path: 'passkey.json' })
  3. In your main test project, configure use: { storageState: 'passkey.json' }
  4. Write 3 tests that each start with the user already passkey-authenticated
  5. Run them in parallel — each gets its own context with the seeded passkey
  6. Verify no test interferes with another

📝 Quiz

Test your understanding. Click an option to check your answer.

1. What does browserContext.credentials.create() do?
A) Creates a new browser context
B) Seeds a passkey (private key, public key, ID, userHandle) into the virtual authenticator
C) Creates a new WebAuthn challenge
D) Creates a new user account
Answer: B — create() seeds a passkey into the context's virtual authenticator. You must call install() afterwards to activate it.
2. Which method activates the virtual authenticator?
A) credentials.activate()
B) credentials.install()
C) credentials.start()
D) credentials.enable()
Answer: B — credentials.install() activates the virtual authenticator so the browser answers WebAuthn ceremonies using seeded passkeys.
3. Do passkeys persist across browser contexts?
A) Yes, automatically
B) No — they are scoped to the context that created them
C) Only on Chromium
D) Only if the context is not closed
Answer: B — Passkeys are per-context. Use storageState (1.61+ includes credentials) or credentials.get() + create() to re-seed.
4. What key format does the privateKey field expect?
A) JWK
B) PEM
C) PKCS#8
D) Raw bytes
Answer: C — privateKey must be PKCS#8 format. publicKey must be SPKI format. Convert from other formats before seeding.
5. Why can’t you mock navigator.credentials with addInitScript?
A) It’s not possible to mock navigator
B) The mock signature is invalid — backend verification fails
C) Playwright blocks addInitScript for security
D) It works fine, there’s no problem
Answer: B — A mock returns a fake signature that the backend rejects because it doesn't match the registered public key. The virtual authenticator signs with the real private key, so verification succeeds.
6. Does storageState include WebAuthn credentials in 1.61+?
A) No, only cookies and localStorage
B) Yes, via the credentials option
C) Only if explicitly configured
D) Only on Chromium
Answer: B — 1.61+ adds a credentials option to storageState that includes virtual WebAuthn passkeys. Save once, load into many contexts.
7. Can you seed multiple passkeys for the same relying party?
A) No, only one per origin
B) Yes — call create() multiple times
C) Only if they have different user handles
D) Only on Firefox
Answer: B — Call credentials.create() multiple times with different credential IDs. The authenticator answers with whichever matches the challenge's allowCredentials list.
8. Which Playwright version introduced browserContext.credentials?
A) 1.59
B) 1.60
C) 1.61
D) 1.62
Answer: C — browserContext.credentials shipped in Playwright 1.61 (June 2026).
9. What happens if you call install() before create()?
A) It works fine
B) The authenticator is active but empty — ceremonies fail with 'no matching credential'
C) Playwright throws an error
D) The browser crashes
Answer: B — install() activates the authenticator, but with no seeded passkeys, navigator.credentials.get() returns no matching credential. Always create() first, then install().
10. Which browsers support the virtual authenticator?
A) Chromium only
B) Chromium and Firefox
C) Chromium, Firefox, and WebKit
D) Only headless Chromium
Answer: C — The virtual authenticator is browser-agnostic. The same test passes on Chromium, Firefox, and WebKit.

❓ 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.