Skip to main content
Back to Blog
Tutorial
2026-07-14

Playwright 1.61 WebAuthn Passkey Testing with Virtual Authenticators

Test passkey registration and sign-in with the cross-browser Credentials virtual authenticator added in Playwright 1.61, including reuse and failure diagnosis.

Playwright browser context connected to a virtual WebAuthn passkey authenticator

Playwright 1.61 adds a first-class, cross-browser virtual WebAuthn authenticator at browserContext.credentials. For Playwright WebAuthn passkey testing, install that authenticator before the application calls navigator.credentials.create() or navigator.credentials.get(), drive the real registration or sign-in UI, and inspect its credentials with create(), get(), or delete(). Unlike the older Chromium-only CDP technique, the 1.61 API works across Chromium, Firefox, and WebKit. Start with the complete Playwright E2E guide if context fixtures and setup projects are new to you.

This guide is specifically about replacing passkey stubs and browser-specific virtual-authenticator plumbing with the supported 1.61 Credentials API. It does not claim that a software authenticator reproduces biometric hardware, operating-system account pickers, cloud passkey synchronization, or every attestation format. The goal is reliable application-level coverage of enrollment, authentication, credential reuse, deletion, and fallback behavior.

What Playwright 1.61 Changed for WebAuthn Testing

The Playwright 1.61 release notes introduce browserContext.credentials as a virtual authenticator that answers page calls to navigator.credentials.create() and navigator.credentials.get(). No physical key is required, and Playwright states that the API works in all three supported browser engines. The authenticator is scoped to one BrowserContext, so passkeys do not leak into the fresh context Playwright Test creates for another test.

The distinction from older material is important. Before 1.61, a common Playwright pattern opened a Chrome DevTools Protocol session with context.newCDPSession(page) and sent commands from CDP's WebAuthn domain. That remains a possible Chromium-specific integration, and the BrowserContext API reference still documents that CDP sessions are Chromium-only. It is not the new 1.61 API and should not be presented as cross-browser.

Testing needPlaywright 1.61 approachOlder supported approachPractical consequence
Register or use a virtual passkeycontext.credentials.install()Chromium CDP WebAuthn commandsPrefer 1.61 for one test design across browser projects
Seed a known passkeycredentials.create(rpId, keyMaterial)CDP credential commands or app-specific stubs1.61 accepts portable base64url key material
Read a passkey created by the pagecredentials.get({ rpId })CDP credential inspectionA setup project can capture and reuse enrollment
Remove a credentialcredentials.delete(id)CDP removal commandTest a registered account with no matching device credential
Configure transport or user-presence detailsNot exposed by the 1.61 Credentials optionsCDP has lower-level Chromium controlsKeep CDP only for a narrowly justified Chromium-only case
Save cookies and local storagestorageStateSame established APIPasskeys are separate and must be seeded imperatively

That last row prevents a subtle setup failure. The official authentication guide says passkeys are not loaded through the storageState option. Cookies and local storage can initialize a context declaratively; a passkey must be created and the virtual authenticator installed in a context fixture.

Upgrade and Confirm the API Before Debugging the App

Pin Playwright 1.61 or newer in the project that executes these examples, then install the matching browser binaries:

pnpm add -D @playwright/test@1.61.0
pnpm exec playwright install
pnpm exec playwright test --version

Keep the @playwright/test package and downloaded browsers aligned. If TypeScript reports that credentials does not exist on BrowserContext, inspect the package resolved in the workspace rather than adding a type cast. A monorepo can easily execute an older hoisted package even after a different package.json was edited.

The Credentials API reference documents four methods in 1.61:

  • install() intercepts WebAuthn creation and retrieval ceremonies in current and future pages in the context.
  • create(rpId, options?) seeds a discoverable credential. With only an RP ID, Playwright generates an ECDSA P-256 key pair, credential ID, and user handle.
  • get(options?) returns credentials held by the authenticator and can filter by RP ID or credential ID.
  • delete(id) removes either a seeded credential or one registered by the application.

Call install() before the first page code touches navigator.credentials. Calling create() alone only fills the virtual authenticator; it does not intercept the page API.

Understand the Credential and Server Relationship

A WebAuthn login succeeds only when two sides agree. The authenticator holds a credential ID and private key. The application's backend holds the matching credential ID and public key for the user and relying party. Seeding a randomly generated client credential does not magically register its public key with your server.

Choose one of two valid workflows:

  1. Let the application perform a real enrollment ceremony, then read the resulting credential with credentials.get() and save it for later tests.
  2. Provision the public credential on the test backend first, then seed its matching ID, user handle, private key, and public key into each test context.

The first workflow proves the registration UI and backend ceremony. The second makes sign-in tests faster and more focused, but only if backend provisioning is an explicit test-data operation. Passing just context.credentials.create('app.example.test') is useful when the page will register that credential after installation; it is insufficient for a backend that has never seen the generated public key.

The RP ID is normally the effective domain, not a full URL. For https://login.example.test/passkeys, the usual value is login.example.test or a parent domain deliberately selected by the server's WebAuthn options. Do not include https://, a port, or a path. Derive the host from the configured base URL when the deployment changes between local, staging, and CI environments.

Example 1: Register a Passkey and Capture It

The following is a complete Playwright Test setup file. Set PASSKEY_BASE_URL to your test deployment and align the three user-facing locators with the application. It installs the authenticator before navigation, performs enrollment through the UI, verifies that exactly one RP credential was created, and writes the credential to Playwright's authentication directory.

// tests/passkey.setup.ts
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';

const baseURL = process.env.PASSKEY_BASE_URL ?? 'https://app.example.test';
const rpId = new URL(baseURL).hostname;
const passkeyFile = path.join(process.cwd(), 'playwright', '.auth', 'passkey.json');

setup('enroll a reusable passkey', async ({ context, page }) => {
  await context.credentials.install();

  await page.goto(new URL('/settings/security', baseURL).href);
  await page.getByRole('button', { name: 'Add a passkey' }).click();
  await expect(page.getByRole('status')).toHaveText('Passkey added');

  const credentials = await context.credentials.get({ rpId });
  expect(credentials).toHaveLength(1);
  expect(credentials[0].rpId).toBe(rpId);

  fs.mkdirSync(path.dirname(passkeyFile), { recursive: true });
  fs.writeFileSync(passkeyFile, JSON.stringify(credentials[0], null, 2), {
    mode: 0o600,
  });
});

Run it as a setup project or directly while validating the integration:

pnpm exec playwright test tests/passkey.setup.ts --project=chromium

The visible success message proves the application completed its enrollment flow; credentials.get({ rpId }) proves the virtual authenticator retained a credential for the intended relying party. Both checks are needed. A UI toast alone could hide a browser-side failure, while a credential alone does not prove the backend accepted and associated the public key.

The saved object includes id, rpId, userHandle, privateKey, and publicKey. The official authentication guide warns that this file contains the private key. Put playwright/.auth in .gitignore, restrict CI artifact publication, and regenerate test credentials if the file is exposed.

Example 2: Seed the Captured Passkey in Every Test Context

Override the built-in context fixture so every test gets a fresh BrowserContext with the saved credential installed. This follows the structure in Playwright's passkey authentication guidance and preserves normal per-test isolation.

// playwright/fixtures.ts
import { test as base } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';

type SavedPasskey = {
  id: string;
  rpId: string;
  userHandle: string;
  privateKey: string;
  publicKey: string;
};

const passkeyFile = path.join(process.cwd(), 'playwright', '.auth', 'passkey.json');

export const test = base.extend({
  context: async ({ context }, use) => {
    const credential = JSON.parse(fs.readFileSync(passkeyFile, 'utf8')) as SavedPasskey;

    await context.credentials.create(credential.rpId, credential);
    await context.credentials.install();
    await use(context);
  },
});

export { expect } from '@playwright/test';

Now the sign-in specification starts with an enrolled virtual device but still exercises the application's real challenge, assertion, and session-establishment path:

// tests/passkey-login.spec.ts
import { test, expect } from '../playwright/fixtures';

const baseURL = process.env.PASSKEY_BASE_URL ?? 'https://app.example.test';

test('signs in with an enrolled passkey', async ({ page }) => {
  await page.goto(new URL('/login', baseURL).href);
  await page.getByRole('button', { name: 'Sign in with a passkey' }).click();

  await expect(page).toHaveURL(/\/dashboard(?:\/|$)/);
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Use a setup-project dependency so the capture file exists before browser projects start. Do not make every parallel test rewrite one shared passkey file. Enrollment belongs in one setup phase; test contexts should read it. If the backend increments a signature counter or otherwise treats concurrent use of one credential specially, provision one passkey per worker or test account instead of serializing the whole suite.

For broader multi-user fixture design, continue to the Playwright BrowserContext isolation guide. It explains why separate pages in one context are not separate identities.

Test a Missing Device Credential Without Mocking the App

Deleting the authenticator's credential while leaving the backend registration intact models a user whose account knows a passkey but whose current device does not hold it. Assert the product's documented recovery route rather than a browser-specific error string:

import { test, expect } from '../playwright/fixtures';

const baseURL = process.env.PASSKEY_BASE_URL ?? 'https://app.example.test';

test('offers account recovery when this device has no passkey', async ({ context, page }) => {
  const [credential] = await context.credentials.get();
  expect(credential).toBeDefined();
  await context.credentials.delete(credential.id);

  await page.goto(new URL('/login', baseURL).href);
  await page.getByRole('button', { name: 'Sign in with a passkey' }).click();

  await expect(page.getByRole('link', { name: 'Use another sign-in method' })).toBeVisible();
});

This test intentionally avoids asserting that every engine emits identical native dialog copy or DOM exception text. The 1.61 authenticator provides a cross-browser application boundary; the durable contract is how your product recovers when no credential can satisfy the request.

Diagnose Passkey Test Failures by Boundary

Treat a failure as one of four boundaries instead of repeatedly changing selectors or adding timeouts.

TypeScript says credentials does not exist

The executing package is older than 1.61 or workspace resolution is stale. Run the package manager's Playwright binary with --version, inspect the lockfile resolution, reinstall matching browsers, and restart the TypeScript language service. Do not hide a real version mismatch with as any.

A native passkey prompt appears or the click hangs

The virtual authenticator was not installed before page code called WebAuthn. Place await context.credentials.install() before navigation or in the context fixture. Seeding with create() is not installation; the API reference explicitly separates the two operations.

Registration UI succeeds but credentials.get returns an empty array

Check the RP ID first. Log new URL(page.url()).hostname and compare it with the RP ID returned by the backend's registration options. Then verify that the page really called navigator.credentials.create() rather than acknowledging a queued or mocked operation. Filter by the actual RP ID only after proving it is correct.

Sign-in has a credential but the server rejects the assertion

The server and authenticator likely do not share the same credential. Confirm that the backend record contains the public key and credential ID paired with the saved private credential, that the test did not mix files from another environment, and that the RP ID matches. Re-enroll through the target environment when in doubt; do not edit base64url key strings by hand.

The test passes in Chromium but fails in Firefox or WebKit

Confirm that the test uses context.credentials, not a retained newCDPSession helper. Then compare application behavior rather than assuming the Playwright API is browser-specific: conditional product code, unsupported browser detection, secure-context configuration, or different RP responses can still create engine-specific results. Capture a trace and the server's ceremony response while keeping private key material out of attachments.

Reused credentials disappear between tests

That is expected when each test receives a fresh context. The authenticator is context-scoped. Seed and install the credential in every context fixture, or capture it in setup and read the file in the fixture. Do not share one context across unrelated tests to preserve the passkey; that trades a small setup step for state leakage.

The test times out after the app reports success

Use web-first assertions and inspect the pending operation. A button click can finish while a server request, redirect, or status update remains incomplete. Wait for an observable URL, heading, response, or status owned by the application. Arbitrary sleeps only obscure whether the WebAuthn ceremony or the post-authentication transition is stuck. The locator stability guide shows how to make those assertions specific.

Migrate a Chromium CDP Helper Deliberately

Do not mechanically rename a CDP command. Replace the lifecycle:

  1. Remove context.newCDPSession(page) and WebAuthn.enable from cross-browser tests.
  2. Install context.credentials before navigation.
  3. Let the page register a credential or seed a complete known credential.
  4. Replace CDP credential listing with credentials.get().
  5. Replace CDP removal with credentials.delete(id).
  6. Run the same behavior in Chromium, Firefox, and WebKit projects.

Keep a CDP-only test only when it validates a lower-level Chromium behavior that the 1.61 API does not expose, such as a specific authenticator transport or user-verification control. Name and tag that test as Chromium-only. The existing virtual authenticator article remains useful for that legacy CDP case; it should not be the default for new portable passkey coverage.

Version Scope and Honest Limitations

This article targets Playwright 1.61, whose release notes list the new Credentials API and browser builds Chromium 149, Firefox 151, and WebKit 26.5. The high-level API is the new part. Browser contexts, fixtures, setup-project authentication, locators, and storageState existed earlier.

The API virtualizes the WebAuthn ceremony at the page/context boundary. Based on the public 1.61 method surface, it does not provide controls for biometric success versus failure, authenticator attachment, transport selection, attestation policy, operating-system account chooser UI, Bluetooth/NFC behavior, or cloud synchronization. Cover those risks with lower-level browser-specific automation where available, integration tests around server policy, and a small manual/device matrix. Do not infer hardware assurance from a passing virtual-authenticator test.

Saved credentials are test secrets. They include private keys, are not part of storageState, and should not appear in repositories, trace attachments, screenshots, or public CI artifacts. Also remember that BrowserContext isolation does not isolate backend accounts. Concurrent tests using the same enrolled user can still race on server-side credential lists, revocation, or audit state.

Keep one assertion at each ceremony boundary: the browser produced or selected a credential, the server accepted the response, and the application established the expected user session. This separation makes a failed trace actionable. If only the final dashboard is asserted, a cached cookie can make passkey login appear healthy even when no WebAuthn assertion occurred. Start the sign-in test without reusable cookie state, inspect the authenticator before the click, and verify the authenticated identity after the redirect. That is stronger evidence than merely observing that a login button disappeared.

For agent-assisted authoring, browse the QA skills directory and the Playwright CLI skill. Use those tools to inspect and debug the application, but keep passkey provisioning and private-key handling in reviewed fixtures rather than prompts or logs. The sibling Web Storage 1.61 guide covers the other major 1.61 browser-state addition.

Frequently Asked Questions

Does Playwright 1.61 support passkey testing in Firefox and WebKit?

Yes. The 1.61 release notes state that the new browserContext.credentials virtual authenticator works in all browsers. That cross-browser claim applies to the new Credentials API, not to browserContext.newCDPSession(), which remains Chromium-only.

Must I call credentials.install before credentials.create?

You may seed a credential before installation, but the page cannot use the virtual authenticator until credentials.install() runs. The safest fixture sequence is create, install, then navigate. For application-driven registration, install first, navigate, and let the page call navigator.credentials.create().

Can storageState save a Playwright virtual passkey?

No. Playwright's authentication guide treats passkeys separately from cookie and local-storage state. Save the object returned by credentials.get(), then call credentials.create(credential.rpId, credential) and credentials.install() in each new context.

What format does a seeded credential use?

The 1.61 reference requires base64url strings for the credential ID and user handle, a base64url PKCS#8 DER private key, and a base64url SPKI DER public key when importing a known credential. Supply all four together. If options are omitted, Playwright generates them.

Why does a generated credential not sign in my existing user?

The backend does not know its public key and credential ID. Either enroll it through the application's real registration flow or provision the corresponding public credential on the backend. A private credential in the browser and an unrelated server record cannot complete the same assertion.

Can the 1.61 API simulate a failed fingerprint or security-key touch?

Not through the documented Credentials options. The public API manages key material, installation, listing, and deletion; it does not expose user-presence or user-verification switches. Test product fallback with missing/deleted credentials, server responses, or a narrowly scoped Chromium CDP test when that exact lower-level behavior is essential.

Should one passkey be shared by all parallel workers?

Only if the backend safely supports concurrent use of that test credential. Contexts remain isolated, but the server account is shared. For tests that enroll, rename, revoke, or inspect mutable credential state, allocate a user and passkey per worker or per scenario.

Is mocking navigator.credentials directly equivalent?

No. A hand-written page mock can be useful for a unit-level error branch, but it bypasses the supported Playwright authenticator and can miss RP ID, challenge, credential, and browser-integration defects. Use context.credentials for the E2E ceremony and reserve direct mocks for deliberately smaller tests.