Automation Testing

Playwright Passkey Testing: WebAuthn Automation

Master Playwright passkey testing. Automate WebAuthn registration, sign-in, and credential reuse while verifying server outcomes and failure cases.

Rahul VR

Senior Automation Test Engineer

Posted on

13/11/2026

Playwright Passkey Testing Webauthn Automation

Effective Playwright passkey testing involves testing registration, sign-in from an unauthenticated session, and reuse of the same passkey. Confirm that the server enrolls the credential and authenticates the intended account. A browser returning a credential is not sufficient evidence of successful authentication. Use Playwright’s built-in credentials API for repeatable application-flow tests and a separate Chromium CDP suite for additional authenticator controls. This guide pins Playwright 1.65.0. The credentials API arrived in 1.61, credential storage-state support in 1.62, and explicit counter preservation in 1.64.

Before you start: You need an authorized, non-production WebAuthn application, disposable accounts, and protected setup/read-only inspection hooks. The examples use TypeScript, Node.js 22.x, same-origin cookie sessions, explicit button-triggered ceremonies, and no attestation-verification requirement. Set PASSKEY_BASE_URL, PASSKEY_RP_ID, and the secret PASSKEY_TEST_API_TOKEN. These are illustrative application contracts, not standard WebAuthn routes. The files were syntax-checked, not dependency-type-checked or executed against an application. This guide focuses on building a robust Playwright passkey testing framework.

Table of Content

1. Choose the Right Approach for Playwright Passkey Testing

Built-In Credentials API for Application-Flow Testing

browserContext.credentials supplies cross-browser WebAuthn emulation. Install it before application code accesses WebAuthn. It intercepts the page’s credential methods; it does not exercise every browser’s native authenticator stack.

The pinned implementation generates ES256 responses: ECDSA using the P-256 curve and SHA-256. It reports successful user presence (UP) and user verification (UV) and uses attestation format none. These are emulator behaviors, not proof of biometric interaction, authenticator-policy enforcement, or attestation validation.

Chromium CDP for Authenticator Controls

Chrome DevTools Protocol (CDP) provides controls for virtual authenticators. Playwright’s CDP sessions are Chromium-specific, and CDP’s WebAuthn domain is experimental. Confirm command compatibility with the pinned browser.

  • Approach: Built-in credentials API | Use it for: Registration, sign-in, account binding, and imported-credential reuse | Do not treat it as: Native authenticator or hardware coverage
  • Approach: Chromium CDP | Use it for: Browser-integrated ceremonies and controlled verification/presence failures | Do not treat it as: Firefox, WebKit, or physical-device validation
  • Approach: Real devices and providers | Use it for: Native prompts, account selection, biometrics, synchronization, and recovery | Do not treat it as: A substitute for deterministic backend regression tests

Keep the built-in and CDP suites separate. An installed interceptor can answer the page request before the native path that the CDP test intends to exercise.

What Still Requires Real-Device Testing

Maintain real-device tests for Face ID, Touch ID, Windows Hello, security keys, cross-device sign-in, and provider synchronization. Software authenticators cannot establish that those interactions occurred. Browser autofill and account-chooser interaction are also different from returning a credential to JavaScript.

2. Define the WebAuthn Test Contract for Playwright Passkey Testing

Separate Authenticator Credentials, Server Records, and Login Sessions

When planning Playwright passkey testing, use three independently managed states:

  • State: Virtual authenticator | Evidence to inspect: Credential ID, RP ID, user handle, key material, counter | What it does not prove: Server enrollment
  • State: Server enrollment | Evidence to inspect: Credential-to-account association and verification data | What it does not prove: An authenticated browser session
  • State: Application session | Evidence to inspect: Cookies and the authenticated identity | What it does not prove: That this test used a passkey

Registration persists server-side verification data. Authentication uses that enrollment to verify an assertion. Restoring a signed-in session skips the sign-in journey rather than testing it.

Agree on RP ID, Origin, Account Binding, and Verification Policy

The relying party (RP) is the application using WebAuthn. Configure the allowed origin separately from the RP ID: the origin includes scheme and any nondefault port; the RP ID does not. Use valid HTTPS, or an appropriate localhost development origin.

For this contract, encode credential IDs, challenges, and userHandle as canonical, unpadded base64url. Keep account.id, your internal application identifier, separate from the encoded WebAuthn user handle. The handle must represent 1 to 64 opaque bytes and must not contain an email address or other personally identifying data.

The application converts JSON strings to browser buffers before navigator.credentials.create() or get() and serializes the returned credential before posting it to verification. Make the adapter responsible for this conversion; do not compare raw bytes, padded base64, and base64url as interchangeable strings.

The examples require residentKey: ‘required’, userVerification: ‘required’, and ES256. These are selected test prerequisites, not universal product defaults. Hosted identity providers, cross-origin frames, and redirect-based login need a separate origin/session/endpoint contract; do not broaden this example’s accepted origins merely to make such flows pass.

Identify Application-Specific Setup and Evidence

Implement or adapt the following interfaces. Options endpoints return options JSON directly; verification requests post the serialized credential directly, with its id at the top level.

  • Illustrative interface: PUT /__test/accounts/{lease} | Required contract: Protected provisioning; 200/201 with id, email, userHandle, and a single-use same-origin bootstrapPath
  • Illustrative interface: GET /__test/accounts/{lease}/credentials | Required contract: Protected, sanitized array of { id, userId, userHandle, counter }; no private keys
  • Illustrative interface: DELETE /__test/accounts/{lease} | Required contract: Idempotent removal of the account, enrollments, sessions, and challenges; 204/404
  • Illustrative interface: GET /api/me | Required contract: 401 without a session; otherwise 200 with { userId }; no redirect
  • Illustrative interface: POST /api/webauthn/register/options and /verify | Required contract: Real registration options and verification; accepted verification is 200 with { verified: true }
  • Illustrative interface: POST /api/webauthn/authenticate/options and /verify | Required contract: Real authentication; success as above; the CDP failure case expects 400 with { verified: false }

UI contract: /settings/passkeys provides the button “Create a passkey”; /login provides the label “Email” and buttons “Sign in with a passkey” and “Sign in without a username.” Authenticated pages provide “Sign out.” Add these stable completion/error targets:

  • Test ID: signed-in-email | Expected visible state: Exact test-account email after bootstrap and successful sign-in
  • Test ID: passkey-status | Expected visible state: Passkey added after accepted enrollment
  • Test ID: login-form | Expected visible state: Visible after sign-out completes
  • Test ID: passkey-error | Expected visible state: “Passkey sign-in failed. Try again.” after rejected authentication; the sign-in button is visible and enabled

These strings and status codes belong to the example application, not the WebAuthn standard. Adapt them together with the code.

Never expose authentication-bypass setup hooks in production. Restrict them to an isolated test deployment and disposable accounts; authenticate administrative calls with a runner-held secret. The bootstrap creates an enrollment session only. Happy-path WebAuthn verification remains real and unmocked.

3. Build an Isolated Playwright Passkey Fixture

Pin Dependencies and Install the Emulator Before Application Code Runs

Use a supported, patched Node.js 22.x runtime. For a new CommonJS sample project, initialize and install the dependencies below. Existing projects should retain their own compatible module settings. Commit the generated package manifest and lockfile.

npm init -y
npm pkg set type=commonjs
npm install --save-dev --save-exact @playwright/[email protected] [email protected] @types/node@22
npx playwright install chromium firefox webkit
  

Keep the files together:

  • playwright.config.ts
  • tsconfig.json
  • package.json # Generated; type is commonjs.
  • package-lock.json # Generated by npm; commit it.
  • tests/passkey.fixture.ts
  • tests/passkey.steps.ts
  • tests/passkey.spec.ts
  • tests/webauthn.cdp.spec.ts
  • fragments/storage-state.ts.txt # Optional fragment, not a test file.
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022", "DOM"],
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true
  },
  "include": ["playwright.config.ts", "tests/**/*.ts"],
  "exclude": ["node_modules", "fragments"]
}
  

This configuration uses NodeNext resolution with a CommonJS package. For an ESM repository, align the package type, imports, and compiler settings instead of copying this module setup unchanged. Playwright transforms TypeScript but does not perform semantic type checking; run npx tsc -p tsconfig.json –noEmit separately.

import { defineConfig } from '@playwright/test';

const baseURL = process.env.PASSKEY_BASE_URL;
if (!baseURL) throw new Error('Set PASSKEY_BASE_URL');

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  timeout: 45_000,
  expect: { timeout: 10_000 },
  use: {
    baseURL, headless: true,
    storageState: { cookies: [], origins: [] },
    trace: 'off', screenshot: 'off', video: 'off',
  },
  projects: [
    ...(['chromium', 'firefox', 'webkit'] as const).map(
      browserName => ({ name: browserName, use: { browserName },
      testIgnore: '**/*.cdp.spec.ts',
    })),
    {
      name: 'chromium-cdp', use: { browserName: 'chromium' },
      testMatch: '**/*.cdp.spec.ts',
    },
  ],
});
  

The 45-second test limit and 10-second waits are harness limits, not passkey performance targets. Raw recordings are disabled until an appropriate restricted-artifact policy is in place.

Allocate Disposable Accounts and Clean Up Test State

One account lease per test attempt prevents enrollment and session state leaking between tests. The provisioning hook must support idempotent cleanup by lease even when provisioning succeeds but its response is lost. Add server-side expiry for accounts abandoned by interrupted runners.

import { randomUUID } from 'node:crypto';
import { test as base, expect } from '@playwright/test';

export { expect };
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Set ${name}`);
  return value;
}

export const rpId = required('PASSKEY_RP_ID');
export const adminHeaders = {
  'X-Test-Token': required('PASSKEY_TEST_API_TOKEN'),
};

export type Account = {
  id: string; email: string; userHandle: string;
  bootstrapPath: string; testPath: string;
};

export function assertBase64Url(
  value: unknown, field: string, maxBytes = Infinity,
): asserts value is string {
  if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error(`${field}: expected nonempty unpadded base64url`);
  }
  const bytes = Buffer.from(value, 'base64url');
  if (bytes.toString('base64url') !== value || bytes.length > maxBytes) {
    throw new Error(`${field}: noncanonical encoding or invalid length`);
  }
}

// This base fixture does not install WebAuthn interception.
export const appTest = base.extend<{ account: Account }>({
  account: async ({ request, baseURL }, use) => {
    if (!baseURL) throw new Error('Missing baseURL');
    const testPath = `/test/accounts/${randomUUID()}`;
    const errors: Error[] = [];
    let phase = 'setup: account provisioning';
    try {
      const response = await request.put(testPath, {
        headers: adminHeaders, maxRedirects: 0, timeout: 10_000,
      });
      phase = `setup: provisioning HTTP ${response.status()}`;
      expect([200, 201]).toContain(response.status());
      phase = 'setup: account contract';
      const account = await response.json() as Account;
      if (!account || ['id', 'email', 'userHandle', 'bootstrapPath']
          .some(key => typeof account[key as keyof Account] !== 'string' ||
              !account[key as keyof Account]) {
        throw new Error('Invalid account contract');
      }
      assertBase64Url(account.userHandle, 'userHandle', 64);
      const bootstrap = new URL(account.bootstrapPath, baseURL);
      if (bootstrap.origin !== new URL(baseURL).origin ||
          !account.bootstrapPath.startsWith('/')) {
        throw new Error('Bootstrap must stay on the test origin');
      }
      phase = 'fixture use';
      await use({ ...account, testPath });
    } catch {
      // Do not print bodies, bootstrap tokens, or request headers.
      errors.push(new Error(`${phase} failed; raw details withheld`));
    } finally {
      let status = 'unavailable';
      try {
        const response = await request.delete(testPath, {
          headers: adminHeaders, maxRedirects: 0, timeout: 10_000,
        });
        status = String(response.status());
        expect([204, 404]).toContain(response.status());
      } catch {
        errors.push(new Error(`cleanup: deletion failed (HTTP ${status})`));
      }
    }
    if (errors.length) throw new AggregateError(errors, 'Account fixture');
  },
});

export const test = appTest.extend({
  context: async ({ context }, use) => {
    await context.credentials.install();
    await use(context);
  },
});
  

The context override installs interception before the dependent page fixture is used. The account fixture aggregates sanitized setup/use and cleanup failures, including available HTTP status, instead of letting a failed deletion overwrite a setup failure. Raw request bodies and bootstrap secrets stay out of those errors. Test assertions remain reported by Playwright; account expiry is still required when a runner cannot complete teardown.

For broader ownership and parallel-run design in your Playwright passkey testing, see Codoid’s Playwright Test Architecture guide.

4. Automate WebAuthn Passkey Registration in Playwright Passkey Testing

Trigger Registration and Wait for Server Verification

In a robust Playwright passkey testing suite, start from the disposable account’s enrollment session and verify its identity. Register response waiters before clicking; then require accepted verification and a visible completion state. The shared helper file also contains the sign-in and sign-out steps used below.

import type { Page, APIRequestContext } from '@playwright/test';
import {
    expect, rpId, adminHeaders, assertBase64Url, type Account,
} from './passkey.fixture';

export function nextPost(page: Page, path: string) {
    const origin = new URL(page.url()).origin;
    return page.waitForResponse(response =>
        new URL(response.url()).origin === origin &&
        new URL(response.url()).pathname === path &&
        response.request().method() === 'POST', { timeout: 10_000 });
}

export async function assertIdentity(page: Page, account: Account) {
    const me = await page.request.get('/api/me', { maxRedirects: 0 });
    expect(me.status()).toBe(200);
    expect(await me.json()).toMatchObject({ userId: account.id });
    await expect(page.getByTestId('signed-in-email')).toBeVisible();
    await expect(page.getByTestId('signed-in-email')).toHaveText(account.email);
}

export async function signOut(page: Page) {
    await page.getByRole('button', { name: 'Sign out', exact: true }).click();
    await expect(page.getByTestId('login-form')).toBeVisible();
    await expect.poll(async () =>
        (await page.request.get('/api/me', { maxRedirects: 0 })).status(),
    ).toBe(401);
}
  
export async function registerThroughUI(page: Page, account: Account) {
  await page.goto(account.bootstrapPath);
  await assertIdentity(page, account);
  await page.goto('/settings/passkeys');
  const [optionsResponse, verified] = await Promise.all([
    nextPost(page, '/api/webauthn/register/options'),
    nextPost(page, '/api/webauthn/register/verify'),
    page.getByRole('button', { name: 'Create a passkey', exact: true }).click(),
  ]);
  expect(optionsResponse.status()).toBe(200);
  const options = await optionsResponse.json();
  assertBase64Url(options.challenge, 'registration challenge');
  expect(options.rp.id).toBe(rpId);
  expect(options.user.id).toBe(account.userHandle);
  expect(options.pubKeyCredParams).toContainEqual({
    type: 'public-key', alg: -7,
  });
  expect(options.authenticatorSelection).toMatchObject({
    residentKey: 'required', userVerification: 'required',
  });
  expect(verified.status()).toBe(200);
  expect((await verified.json()).verified).toBe(true);
  const id: unknown = verified.request().postDataJSON().id;
  assertBase64Url(id, 'registration credential ID');
  await expect(page.getByTestId('passkey-status')).toBeVisible();
  await expect(page.getByTestId('passkey-status')).toHaveText('Passkey added');
  return id;
}
export async function signIn(
  page: Page, account: Account, id: string,
  mode: 'username-first' | 'discoverable', previousChallenge?: string,
) {
  await page.goto('/login');
  const before = await page.request.get('/api/me', { maxRedirects: 0 });
  expect(before.status()).toBe(401);
  if (mode === 'username-first') {
    await page.getByLabel('Email', { exact: true }).fill(account.email);
  }
  const button = mode === 'username-first'
  ? 'Sign in with a passkey' : 'Sign in without a username';
  const [optionsResponse, verified] = await Promise.all([
    nextPost(page, '/api/webauthn/authenticate/options'),
    nextPost(page, '/api/webauthn/authenticate/verify'),
    page.getByRole('button', { name: button, exact: true }).click(),
  ]);
  expect(optionsResponse.status()).toBe(200);
  const options = await optionsResponse.json();
  assertBase64Url(options.challenge, 'authentication challenge');
  expect(options.challenge).not.toBe(previousChallenge);
  expect(options.rpId).toBe(rpId);
  expect(options.userVerification).toBe('required');
  const allowed = (options.allowCredentials ?? [])
  .map((item: { id: string }) => item.id);
  if (mode === 'discoverable') expect(allowed).toEqual([]);
  else expect(allowed).toContain(id);
  expect(verified.status()).toBe(200);
  expect((await verified.json()).verified).toBe(true);
  expect(verified.request().postDataJSON().id).toBe(id);
  await assertIdentity(page, account);
  return options.challenge as string;
}
  
export async function assertStored(
    request: APIRequestContext, account: Account, id: string, counter: number,
) {
    const response = await request.get(`${account.testPath}/credentials`, {
        headers: adminHeaders, maxRedirects: 0, timeout: 10_000,
    });
    expect(response.status()).toBe(200);
    expect(await response.json()).toEqual([{
        id, userId: account.id, userHandle: account.userHandle, counter,
    }]);
}
  

The pubKeyCredParams assertion checks that the application offers { type: ‘public-key’, alg: -7 }, matching the ES256 prerequisite. The emulator is not a full algorithm-negotiation test: verify unsupported algorithms in a non-intercepted browser/CDP or backend verifier test.

Inspect the Created Credential and Confirm Account Association

The lifecycle test connects the submitted credential ID to the emulator record and the sanitized server record. The expected user handle and account ID must agree with the test contract; a success toast alone cannot establish that association. The exact-one-record assertion is appropriate only because this fixture provisions an empty account.

Do not compare public keys using unrelated encodings. Playwright exports SPKI (SubjectPublicKeyInfo) public-key data; server libraries may store a different representation. Normalize formats deliberately or test through the verifier rather than assuming two encoded strings should match.

Test Duplicate-Registration Protection

For an excluded-credential test, retain the existing credential, require its ID in excludeCredentials, and attempt enrollment again. Assert a recoverable UI result and an unchanged server credential count. Separately, when the product permits another passkey, enroll a distinct credential and verify both belong to the intended account. Excluded-credential protection does not mean an account may have only one passkey.

5. Automate Passkey Sign-In in Playwright Passkey Testing

Start Unauthenticated and Authenticate with the Registered Credential

Every sign-in below in your Playwright passkey testing suite requires /api/me to return 401 before the ceremony, then checks accepted verification, the expected account ID, and the displayed email. The browser-bound request client shares the context’s cookies. Token-based applications must adapt the identity check to their actual token path.

Important: The imported-credential cases below prove reuse, not the original credential’s discoverability. Playwright makes seeded credentials discoverable. The first test verifies original discovery before any export or reseeding.

import { test, expect, rpId } from './passkey.fixture';
import {
    registerThroughUI, signIn, signOut, assertStored,
} from './passkey.steps';

test('original credential supports username-free sign-in', async ({
    page, account,
}) => {
    const id = await registerThroughUI(page, account);
    await signOut(page);
    // Keep the original authenticator. No export, snapshot, or reseeding.
    await signIn(page, account, id, 'discoverable');
});

for (const mode of ['username-first', 'discoverable'] as const) {
    test(`imported credential reuse: ${mode}`, async ({
        page, context, browser, request, account, baseURL,
    }) => {
        const id = await registerThroughUI(page, account);
        const captured = await context.credentials.get({ rpId });
        expect(captured.length).toBe(1); // Never print private-key objects.
        let credential = captured[0]!;
        expect(credential.id).toBe(id);
        expect(credential.userHandle).toBe(account.userHandle);
        await assertStored(request, account, id, credential.signCount);
        let previousChallenge: string | undefined;

        for (let attempt = 0; attempt < 2; attempt++) {
            const fresh = await browser.newContext({
                baseURL, storageState: { cookies: [], origins: [] },
            });
            try {
                await fresh.credentials.create(rpId, credential);
                await fresh.credentials.install();
                const login = await fresh.newPage();
                previousChallenge = await signIn(
                    login, account, id, mode, previousChallenge,
                );
                const updated = await fresh.credentials.get({ id });
                expect(updated.length).toBe(1);
                expect(updated[0]!.signCount).toBe(credential.signCount + 1);
                credential = updated[0]!;
                await assertStored(request, account, id, credential.signCount);
            } finally {
                await fresh.close();
            }
        }
    });
}
  

Cover Username-First and Discoverable-Credential Flows

The original-credential test registers once, keeps the same authenticator, signs out, confirms the unauthenticated state, and requires an empty credential allowlist. It then checks that the submitted credential authenticates the expected account. No credential export, storage snapshot, or recreation occurs in that test.

The two imported-credential cases instead create a new context for each sign-in and carry the latest credential state forward. Username-first sign-in requires the expected ID in the allowlist; username-free sign-in requires no listed credentials. The consecutive-challenge check detects accidental reuse in these attempts; it does not prove randomness or the server’s full replay policy.

Neither test validates conditional autofill or a native account picker. Conditional mediation involves a pending browser request and user selection; the interceptor also overrides capability checks. Keep native capability, selection, and fallback tests separately configured.

6. Reuse Passkey Credentials in Playwright Passkey Testing

Capture and Reseed a Registered Test Credential

Transfer the credential ID, user handle, matching key material, RP ID, and current counter together. Keep its server enrollment alive. A fresh random key with the same account label is not the registered passkey, and creating a virtual credential does not enroll it on the server.

This is test-fixture reuse, not provider synchronization. The local warning beside the test is important: imported credentials cannot demonstrate that the original registration produced a discoverable credential.

Save Virtual Credentials with Storage State

storageState({ credentials: true }) opts into saving virtual credentials, including their private keys and counters. Restoring a state containing them installs the virtual authenticator. Remove session-bearing state when the purpose is to test authentication, not bypass it.

Optional TypeScript fragment, not a sixth runnable test file:

// Fragment: sourceContext, browser, and baseURL come from your test.
const snapshot = await sourceContext.storageState({ credentials: true });
const fresh = await browser.newContext({
  baseURL,
  storageState: { ...snapshot, cookies: [], origins: [] },
});
try {
  // Assert /api/me returns 401, then perform the normal sign-in flow.
  // Keep the matching server enrollment alive throughout this test.
} finally {
  await fresh.close();
}
  

Use this instead of explicit reseeding, not in addition to it. For cross-run reuse, provide a separate managed enrollment lifetime: the per-test fixture above intentionally deletes its account. Save only after in-flight ceremonies finish, and keep credential state in protected, ignored storage with short retention. Never publish a fixture containing working private keys.

Preserve Counters and Avoid Shared-Credential Races

When scaling Playwright passkey testing, a stale snapshot can explain why reuse works once and then fails. After authentication updates the server’s counter, restoring an older credential state can produce a non-increasing counter. Capture the current state rather than resetting the server to hide the failure.

The example’s +1 assertion assumes one emulator assertion per button-triggered attempt. It is not a universal authenticator rule: some authenticators legitimately report zero. Follow the verifier and product policy for those cases.

Assign separate credentials to parallel workers and browser projects. A sequential reuse test should own its entire lifecycle; cloning one mutable snapshot into concurrent tests creates avoidable counter races.

7. Use Chromium CDP for Advanced Playwright Passkey Testing

Configure a Virtual Authenticator and Observe Credential Events

This separate file imports appTest, which does not install the interceptor. It registers through the real UI, signs out, and requests an invalid signature. Credential events show authenticator activity; the verification response, identity endpoint, and recovery UI establish the application outcome.

import { appTest as test, expect } from './passkey.fixture';
import { registerThroughUI, signOut, nextPost } from './passkey.steps';

test('invalid signature leaves a recoverable sign-in screen', async ({
  page, account,
}) => {
  const session = await page.context().newCDPSession(page);
  let authenticatorId: string | undefined;
  try {
    await session.send('WebAuthn.enable');
    const added = await session.send('WebAuthn.addVirtualAuthenticator', {
      options: {
        protocol: 'ctap2', transport: 'internal',
        hasResidentKey: true, hasUserVerification: true,
        isUserVerified: true, automaticPresenceSimulation: true,
      },
    });
    authenticatorId = added.authenticatorId;
    let created = 0;
    let asserted = 0;
    session.on('WebAuthn.credentialAdded', event => {
      if (event.authenticatorId === authenticatorId) created++;
    });
    session.on('WebAuthn.credentialAsserted', event => {
      if (event.authenticatorId === authenticatorId) asserted++;
    });
    await registerThroughUI(page, account);
    await expect.poll(() => created).toBe(1);
    await signOut(page);
    await page.goto('/login');
    await page.getByLabel('Email', { exact: true }).fill(account.email);
    await session.send('WebAuthn.setResponseOverrideBits', {
      authenticatorId, isBogusSignature: true,
    });
    const retry = page.getByRole('button', {
      name: 'Sign in with a passkey', exact: true,
    });
    const [rejected] = await Promise.all([
      nextPost(page, '/api/webauthn/authenticate/verify'), retry.click(),
    ]);
    expect(rejected.status()).toBe(400);
    expect((await rejected.json()).verified).toBe(false);
    await expect.poll(() => asserted).toBeGreaterThan(0);
    const me = await page.request.get('/api/me', { maxRedirects: 0 });
    expect(me.status()).toBe(401);
    const error = page.getByTestId('passkey-error');
    await expect(error).toBeVisible();
    await expect(error).toHaveText('Passkey sign-in failed. Try again.');
    await expect(retry).toBeVisible();
    await expect(retry).toBeEnabled();
  } finally {
    try {
      if (authenticatorId) {
        await session.send('WebAuthn.removeVirtualAuthenticator', {
          authenticatorId,
        });
      }
      await session.send('WebAuthn.disable');
    } finally {
      await session.detach();
    }
  }
});
  

Exercise Verification Failures, Missing Presence, and Invalid Responses

Use separate cases so each failure has an identifiable cause:

  • Control: setUserVerified(false) | Action: Attempt a ceremony requiring verification | Required evidence: Failure or deliberate cancellation within the limit; no new session; identify the rejection layer
  • Control: setAutomaticPresenceSimulation(false) | Action: Withhold simulated user presence | Required evidence: Pending ceremony can be deliberately aborted; the UI becomes usable again
  • Control: setResponseOverrideBits | Action: Produce an invalid signature or altered UP/UV flags | Required evidence: Rejection at the intended layer; no new authenticated identity

These are documented controls, not guarantees that every browser version follows the same observable sequence. Disabling automatic presence does not click Cancel. Exercise the application’s cancellation path, independently bound the test, and confirm recovery; a runner timeout alone is not a recovery test.

A browser can reject before sending verification. To prove server enforcement, use a backend case that reaches the verifier with a well-formed, targeted fixture rather than treating an absent request as evidence of server rejection.

8. Build a Regression Matrix for Playwright Passkey Testing

These are acceptance cases to add around the example suite for comprehensive Playwright passkey testing. Start negative authentication cases without a session and require no newly authenticated identity afterward. The tables specify design expectations, not executed results.

Missing, Deleted, Revoked, and Wrong-Account Credentials

  • Preconditions and action: Empty authenticator; attempt sign-in | Layer: Application flow | Expected outcome and evidence: Recoverable error; no accepted verification or session
  • Preconditions and action: Delete the virtual key but retain server enrollment | Layer: Application flow | Expected outcome and evidence: Deleted key cannot authenticate; server record remains
  • Preconditions and action: Revoke enrollment but retain the key | Layer: Browser + backend | Expected outcome and evidence: Server refuses the credential; no new session; revocation evidence
  • Preconditions and action: Submit account A’s credential in B’s username-first ceremony | Layer: Backend + browser | Expected outcome and evidence: No account substitution; intended rejection and no B session
  • Preconditions and action: Enroll A and B; run separate username-free cases with only A’s or B’s credential available | Layer: Application flow | Expected outcome and evidence: A’s ID/handle resolves to A; B’s resolves to B; an empty allowlist and correct displayed identity each time
  • Preconditions and action: Enroll a second, distinct passkey for A using another authenticator | Layer: Application + backend | Expected outcome and evidence: Two distinct IDs mapped to A; each authenticates A independently

For the two-account case, controlled credential availability tests identity mapping, not native picker interaction. A real chooser test must present both eligible credentials and select each intended account in a supported device/browser environment. The pinned interceptor chooses a matching credential programmatically; do not mistake that for a chooser.

Deleting a virtual credential and revoking enrollment are different operations. Define separately whether revocation also invalidates sessions created earlier. For the two-passkey case, adapt the exact-one-record helper to compare the intended two-record set.

Expired Challenges, Replay, Invalid Origin, and RP Mismatch

  • Preconditions and action: Move a server test clock across the configured challenge expiry | Layer: Backend | Expected outcome and evidence: Accept before expiry; reject at/after the product’s documented boundary
  • Preconditions and action: Resubmit an accepted assertion; then race two submissions of one challenge | Layer: Backend | Expected outcome and evidence: No replay acceptance; at most one concurrent acceptance; challenge-consumption evidence
  • Preconditions and action: Send a validly constructed assertion from an unapproved origin | Layer: Backend | Expected outcome and evidence: Origin rejection rather than an unrelated parsing failure
  • Preconditions and action: Request a disallowed RP ID without the interceptor | Layer: Native browser/CDP | Expected outcome and evidence: Browser rejection; no accepted authentication
  • Preconditions and action: Verify an assertion for a different RP ID | Layer: Backend | Expected outcome and evidence: RP-ID-hash rejection; no new session

Use your verifier’s test utilities for targeted fixtures. Editing clientDataJSON without rebuilding the signed fixture can also break the signature, making the failure ambiguous. Require a sanitized internal verification reason while keeping public authentication errors appropriately generic.

Network Failures, Retries, and Concurrent Attempts

  • Preconditions and action: Fail options retrieval before credential creation/use | Layer: Browser + network fault | Expected outcome and evidence: Clear error, enabled retry, no session
  • Preconditions and action: Lose the verification response after possible server acceptance | Layer: Integration | Expected outcome and evidence: Reconcile actual identity/session state; do not blindly replay the assertion
  • Preconditions and action: Double-click or start competing ceremonies | Layer: Browser + backend | Expected outcome and evidence: Documented concurrency behavior; no account mix-up or duplicate acceptance
  • Preconditions and action: Fail enrollment verification after local key creation | Layer: Integration | Expected outcome and evidence: No false enrollment success; explicit orphaned-key and retry handling
  • Preconditions and action: Use an unsupported capability or controlled passkey failure | Layer: Separately configured UI/native case | Expected outcome and evidence: Approved fallback becomes available; no session before fallback; intended identity and success UI only after fallback succeeds

For unsupported-capability UI testing, omit the built-in interceptor and stub only the application’s capability boundary. Label this a UI-contract test, not device validation. Confirm actual unsupported-device behavior separately. Do not invent a password or recovery fallback that the product does not support.

Distinguish a request that never reached the server from an accepted request whose response was lost. Check the resulting session before choosing recovery; when another authentication is needed, begin a new ceremony with a new challenge.

Codoid’s API Automation Testing with Postman, REST Assured, and Playwright guide provides complementary guidance for organizing backend checks.

9. Run Playwright Passkey Testing in CI and Diagnose Failures

Isolate Workers, Retries, and Browser Projects

To run Playwright passkey testing reliably, the configuration explicitly uses headless mode. These virtual-authenticator tests are intended for CI; they still require working application endpoints, browser dependencies, and compatible pinned binaries. Headless execution does not add native-device coverage.

After initial installation has generated a lockfile, use this sequence on the authorized deployment:

# Requires a generated and committed package-lock.json.
npm ci
npx tsc -p tsconfig.json --noEmit
npx playwright install --with-deps chromium firefox webkit
npx playwright test --project=chromium --project=firefox --project=webkit
npx playwright test --project=chromium-cdp
  

The TypeScript check includes all five named files. Record the resolved package versions, browser revisions, application build, expected origin/RP ID, sanitized outcomes, and cleanup status. Give retries fresh leases unless one test deliberately owns the sequential recovery scenario. Separate account ownership matters when tests modify server state.

Collect Evidence Without Exposing Credentials

Record the test ID, browser project, flow, sanitized verification decision, account alias, observed counter transition, and cleanup result. Restrict artifacts containing credential IDs and user handles as well as clearly secret values.

Credential exports contain private keys; saved authentication state can contain impersonation-capable cookies. Keep both out of Git and ordinary logs. Do not pass complete private-key-bearing objects into assertions that might print them. Recordings can also expose bootstrap tokens and protected API requests; enabling them requires access controls, redaction where possible, and defined retention.

Troubleshoot Enrollment, Sign-In, and Reuse Failures

  • Symptom: Native prompt appears in the application-flow suite | Check next: Fixture import and installation order
  • Symptom: Sign-in passes without a verification request | Check next: Restored session state; missing pre-sign-in 401 assertion
  • Symptom: Reuse works once, then fails | Check next: Stale counter snapshot or concurrent owners
  • Symptom: CDP controls seem ineffective | Check next: Accidental interceptor; incorrect CDP target
  • Symptom: Discovery works only after import | Check next: Original resident-key request; repeat the no-reseeding test
  • Symptom: NotAllowedError | Check next: Cancellation, timeout, unavailable matching credential, and whether verification was sent
  • Symptom: InvalidStateError during registration | Check next: Excluded existing credential and whether enrollment count changed
  • Symptom: SecurityError | Check next: Origin/effective-domain and RP-ID relationship in the non-intercepted browser
  • Symptom: NotSupportedError | Check next: Offered public-key algorithms and actual authenticator support
  • Symptom: AbortError | Check next: Application cancellation signal and recovery state
  • Symptom: TypeError | Check next: Options shape, buffer conversion, and user-handle byte length

Error names are diagnostic clues, not a one-to-one cause map. The browser and emulator may differ, and an exception alone does not prove that a server request occurred. Use the layer-specific evidence alongside the error.

CONCLUSION

Release-readiness takeaway: Require accepted enrollment, original-discoverability evidence where needed, a genuinely unauthenticated starting state, the correct authenticated identity, visible completion/recovery, and repeatable reuse. Place each negative test at the layer that can demonstrate the intended rejection. Treat native-device behavior as a separate release obligation. A complete Playwright passkey testing strategy covers all these boundaries.

For support integrating these checks into a maintainable suite, explore Codoid’s QA automation testing services.

Ready to Automate Your WebAuthn Flows?

Talk to an Automation Expert

Frequently Asked Questions

  • Can Playwright test passkeys in Firefox and WebKit?

    Yes, through the built-in credentials API's cross-browser emulation. This does not establish coverage of those platforms' native authenticator stacks. The CDP example remains Chromium-specific.

  • Does Playwright storage state save passkeys?

    It can include virtual credentials when credentials: true is requested. It does not export passkeys from an operating system or a real password manager. Remove session-bearing state when testing sign-in.

  • Does adding a virtual credential register it with the application?

    No. It populates the emulator. The server must already hold the matching enrollment, created through registration or an explicitly provisioned test fixture.

  • Can virtual-authenticator tests validate Face ID or Touch ID?

    No. They validate application behavior around simulated credential operations, not actual biometric interaction. Keep biometric acceptance in the real-device plan.

Comments(0)

Submit a Comment

Your email address will not be published. Required fields are marked *

Talk to our Experts

Amazing clients who
trust us


poloatto
ABB
polaris
ooredo
stryker
mobility