QA How-To
How to Test WebAuthn Passkeys With Playwright TypeScript (2026)
Learn to test WebAuthn passkeys with Playwright TypeScript using Chromium virtual authenticators, registration, sign-in, negative cases, and CI checks.
22 min read | 2,373 words
TL;DR
Create a Chromium CDP session, enable the WebAuthn domain, and add a CTAP2 virtual authenticator before using the page. Drive the real navigator.credentials.create() and navigator.credentials.get() calls through the UI, then assert both application behavior and virtual credential state.
Key Takeaways
- Use a Chromium CDP session and the WebAuthn domain to create a deterministic virtual authenticator.
- Test registration and authentication as separate ceremonies with assertions on both UI and credential state.
- Set resident-key and user-verification capabilities explicitly so the emulator matches the passkey flow under test.
- Use WebAuthn.getCredentials to inspect authenticator state without exposing application secrets.
- Cover missing credentials, failed user verification, and cleanup instead of testing only the happy path.
- Run passkey tests in Chromium on CI because Playwright exposes the required CDP session only for Chromium-based browsers.
To test webauthn passkeys with playwright typescript, connect Playwright to Chromium's DevTools Protocol, enable the WebAuthn domain, and attach a virtual authenticator to the browser context. Your application still calls the real Web Authentication API, while Chromium supplies deterministic presence, resident-key, and user-verification behavior.
This tutorial builds a small local relying-party page and a reusable Playwright fixture. You will automate passkey creation, passwordless sign-in, credential inspection, rejection when no passkey exists, and failed user verification. For broader authentication coverage, pair this guide with passkey authentication flow testing and JWT authentication testing.
TL;DR
| Concern | Implementation | Assertion |
|---|---|---|
| Browser support | Chromium plus CDPSession |
CDP commands return without protocol errors |
| Authenticator | CTAP2, internal transport, resident keys | WebAuthn.getCredentials returns one credential |
| Registration | navigator.credentials.create() |
UI reports registration and credential count becomes one |
| Authentication | navigator.credentials.get() |
UI reports sign-in and sign counter increases |
| Negative behavior | Empty authenticator or verification disabled | UI exposes a controlled error |
| Cleanup | Remove authenticator after each test | Tests do not leak credentials |
The emulator replaces hardware, not your application ceremony. In production, the server must generate unpredictable challenges, bind them to a session, validate origin and RP ID, verify signatures, enforce flags, and prevent challenge replay. The local page intentionally demonstrates browser automation; it is not a production WebAuthn server.
What You Will Build
By the end, you will have:
- A local HTTPS-equivalent
localhostpage that invokes real WebAuthn browser APIs. - A typed fixture that provisions and removes a virtual passkey authenticator.
- A registration test that confirms resident credential creation.
- An authentication test that checks the authenticator signature counter.
- Negative tests for an absent passkey and failed user verification.
- A Chromium-only CI command with traces retained on failure.
The important boundary is clear: Playwright clicks and observes the user interface, CDP controls test hardware, and WebAuthn remains the browser-facing API.
Prerequisites
Use Node.js 22.18.0 or newer in the Node 22 LTS line, npm 10.9.3 or newer, TypeScript 5.9.2, and @playwright/test 1.55.0. Chromium is required because browserContext.newCDPSession(page) is a Chromium-only Playwright API.
Create an empty project and install exact tutorial versions:
mkdir passkey-playwright && cd passkey-playwright
npm init -y
npm install -D @playwright/test@1.55.0 typescript@5.9.2 @types/node@22.17.2
npx playwright install chromium
Add scripts to package.json:
{
"scripts": {
"demo": "tsx demo/server.ts",
"test": "playwright test",
"test:passkeys": "playwright test tests/passkeys.spec.ts --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@types/node": "22.17.2",
"tsx": "4.20.4",
"typescript": "5.9.2"
}
}
Install the added runner with npm install. If you are building a larger suite, the Playwright TypeScript framework guide explains scalable folders, fixtures, and reporting.
Verify: Run npx playwright --version, npx tsc --version, and node --version. The output should begin with Version 1.55.0, Version 5.9.2, and v22.18.0 respectively.
Step 1: Create a Minimal Passkey Demo
Create demo/index.html. This page performs genuine registration and authentication ceremonies. It keeps the created credential ID in memory so the example can request the same credential during sign-in.
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Passkey Lab</title></head>
<body>
<h1>Passkey Lab</h1>
<button id="register">Create passkey</button>
<button id="login">Sign in with passkey</button>
<output id="status">Ready</output>
<script>
const status = document.querySelector('#status');
let credentialId;
const challenge = () => crypto.getRandomValues(new Uint8Array(32));
document.querySelector('#register').onclick = async () => {
try {
const credential = await navigator.credentials.create({ publicKey: {
challenge: challenge(),
rp: { id: location.hostname, name: 'Passkey Lab' },
user: {
id: new TextEncoder().encode('qa-user-42'),
name: 'qa@example.test',
displayName: 'QA User'
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
authenticatorSelection: {
authenticatorAttachment: 'platform',
residentKey: 'required',
userVerification: 'required'
},
timeout: 10000,
attestation: 'none'
}});
credentialId = credential.rawId;
status.textContent = 'Passkey registered';
} catch (error) {
status.textContent = `Registration failed: ${error.name}`;
}
};
document.querySelector('#login').onclick = async () => {
try {
const publicKey = {
challenge: challenge(),
rpId: location.hostname,
userVerification: 'required',
timeout: 10000
};
if (credentialId) {
publicKey.allowCredentials = [{
type: 'public-key', id: credentialId, transports: ['internal']
}];
}
await navigator.credentials.get({ publicKey });
status.textContent = 'Passkey sign-in successful';
} catch (error) {
status.textContent = `Sign-in failed: ${error.name}`;
}
};
</script>
</body>
</html>
Create demo/server.ts using only Node's HTTP modules:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const htmlPath = fileURLToPath(new URL('./index.html', import.meta.url));
createServer(async (_request, response) => {
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
response.end(await readFile(htmlPath));
}).listen(4173, '127.0.0.1', () => {
console.log('Passkey Lab: http://localhost:4173');
});
localhost is treated as a potentially trustworthy origin for local development, so the browser permits WebAuthn without a local TLS certificate. Production relying parties still require a secure context.
Verify: Run npm run demo, open http://localhost:4173, and confirm the page shows two buttons and Ready. Do not expect manual registration to finish on a machine without an available platform authenticator.
Step 2: Configure Playwright for Chromium
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: 'http://localhost:4173',
trace: 'retain-on-failure'
},
projects: [{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}],
webServer: {
command: 'npm run demo',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI
}
});
A single Chromium project is deliberate. The virtual authenticator is driven through Chromium CDP, not a cross-browser Playwright abstraction. Keep separate non-passkey UI tests for Firefox and WebKit if cross-browser presentation still matters. See cross-browser testing setup for that split.
Verify: Run npx playwright test --list. Playwright should discover the Chromium project without launching a test. If the web server command exits, run npm run demo directly and fix that error first.
Step 3: Add a Typed Virtual Authenticator Fixture
Create tests/fixtures.ts. The fixture enables WebAuthn, adds a CTAP2 internal authenticator, exposes inspection controls, and removes the device during teardown.
import { test as base, expect, type CDPSession } from '@playwright/test';
type WebAuthnFixture = {
cdp: CDPSession;
authenticatorId: string;
};
export const test = base.extend<WebAuthnFixture>({
cdp: async ({ page }, use) => {
const session = await page.context().newCDPSession(page);
await session.send('WebAuthn.enable', {
enableUI: false
});
await use(session);
await session.send('WebAuthn.disable');
await session.detach();
},
authenticatorId: async ({ cdp }, use) => {
const { authenticatorId } = await cdp.send(
'WebAuthn.addVirtualAuthenticator',
{ options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true
}}
);
await use(authenticatorId);
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
}
});
export { expect };
hasUserVerification declares capability, while isUserVerified controls the result of the next ceremony. automaticPresenceSimulation avoids a physical touch prompt. hasResidentKey is essential because the demo requests residentKey: 'required'.
Verify: Create a temporary smoke test that accepts { authenticatorId } and asserts expect(authenticatorId).toBeTruthy(). Run npx playwright test --project=chromium; it should pass without opening a biometric dialog.
Step 4: Test WebAuthn Passkeys With Playwright TypeScript Registration
Create tests/passkeys.spec.ts and add the registration case:
import { test, expect } from './fixtures';
test('registers a discoverable platform passkey', async ({
page, cdp, authenticatorId
}) => {
await page.goto('/');
await page.getByRole('button', { name: 'Create passkey' }).click();
await expect(page.locator('#status')).toHaveText('Passkey registered');
const { credentials } = await cdp.send('WebAuthn.getCredentials', {
authenticatorId
});
expect(credentials).toHaveLength(1);
expect(credentials[0].rpId).toBe('localhost');
expect(credentials[0].userName).toBe('qa@example.test');
expect(credentials[0].isResidentCredential).toBe(true);
expect(credentials[0].signCount).toBe(0);
});
This test asserts at two layers. The visible status proves the application handled the resolved promise. Credential inspection proves Chromium actually stored a resident credential for the expected relying party and user. A UI-only assertion could pass if application code printed success prematurely.
Avoid asserting the generated credential ID or key bytes. Those values are intentionally generated and vary across runs. Assert stable semantics such as RP ID, user name, residency, and credential count.
Verify: Run npm run test:passkeys -- --grep "registers". Expect one passed test and no native authentication prompt. On failure, open the retained trace with npx playwright show-trace <trace.zip>.
Step 5: Test WebAuthn Passkeys With Playwright TypeScript Sign-In
Add a second test to the same file. Registration and authentication happen in one browser page because the demo stores its allow-list ID in memory.
test('authenticates with the registered passkey', async ({
page, cdp, authenticatorId
}) => {
await page.goto('/');
await page.getByRole('button', { name: 'Create passkey' }).click();
await expect(page.locator('#status')).toHaveText('Passkey registered');
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
await expect(page.locator('#status')).toHaveText(
'Passkey sign-in successful'
);
const { credentials } = await cdp.send('WebAuthn.getCredentials', {
authenticatorId
});
expect(credentials).toHaveLength(1);
expect(credentials[0].signCount).toBe(1);
});
The sign counter assertion provides stronger evidence than the success label alone. Chromium increments it after producing an assertion with that credential. Your production server should validate the assertion signature and treat counter behavior according to authenticator characteristics rather than assuming every synced passkey has a strictly increasing counter.
For real systems, also assert the network response from the verification endpoint. Start page.waitForResponse() before the sign-in click, then check the status and a safe response field. Never log complete attestation objects, assertion signatures, session cookies, or access tokens into CI artifacts.
Verify: Run npm run test:passkeys -- --grep "authenticates". The test should pass and signCount should equal one after the single authentication ceremony.
Step 6: Test Missing Credentials and User Verification Failure
Happy paths are insufficient for account access. Add two negative cases:
test('rejects sign-in when no passkey exists', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
await expect(page.locator('#status')).toHaveText(
'Sign-in failed: NotAllowedError'
);
});
test('rejects sign-in when user verification fails', async ({
page, cdp, authenticatorId
}) => {
await page.goto('/');
await page.getByRole('button', { name: 'Create passkey' }).click();
await expect(page.locator('#status')).toHaveText('Passkey registered');
await cdp.send('WebAuthn.setUserVerified', {
authenticatorId,
isUserVerified: false
});
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
await expect(page.locator('#status')).toHaveText(
'Sign-in failed: NotAllowedError'
);
});
The first test starts with an empty authenticator, so discoverable credential lookup cannot satisfy the request. The second creates a valid passkey, then makes required user verification fail. Assert the stable DOM outcome rather than browser-specific error prose. The DOMException name is generally more durable than a localized message.
In a production UI, replace raw exception names with helpful recovery text and a safe alternative such as another passkey, a verified recovery flow, or support escalation. Do not silently fall back to a weaker authentication factor. Review API security testing basics when validating the server endpoints behind these ceremonies.
Verify: Run npm run test:passkeys -- --grep "rejects". Expect two passing tests. Each fixture teardown removes its authenticator, preventing the first test's empty state from depending on execution order.
Step 7: Add CI Execution and Diagnostic Artifacts
Create .github/workflows/passkeys.yml:
name: Passkey tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.18.0
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:passkeys
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
The workflow installs only Chromium because that is the supported target for this CDP technique. Failure-only artifact upload limits exposure and storage. Before enabling traces on authentication suites, verify that your application masks credentials, tokens, and personal data. The GitHub Actions for Playwright guide covers caching, sharding, and report publication.
Verify: Run CI=1 npm run test:passkeys locally. Then push a branch and confirm the Passkey tests job reports four passing tests. Intentionally break one expected status in a temporary local change to confirm a trace is produced, then restore the assertion.
Step 8: Assert the Relying-Party Verification Boundary
The demo isolates browser mechanics, but a production test must prove that the relying party accepts only a valid ceremony. Capture the verification request and response without duplicating cryptography in the test. Your application should serialize the credential response, send it to a server endpoint, and let a maintained WebAuthn server library perform verification.
Suppose your real sign-in page posts to /api/auth/passkey/verify. Add this pattern to the authentication test:
const verificationPromise = page.waitForResponse(response =>
response.url().endsWith('/api/auth/passkey/verify') &&
response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
const verification = await verificationPromise;
expect(verification.status()).toBe(200);
expect(await verification.json()).toMatchObject({ authenticated: true });
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Create the response promise before clicking so a fast local request cannot race past the listener. Assert a minimal public contract instead of snapshots of the entire response. Full payloads often contain session details that should not appear in reports.
Then add server-negative tests through your application or an approved test API. Reuse a captured assertion against a fresh session and expect rejection. Submit an assertion created for a different test RP ID and expect rejection. Expire or invalidate the challenge server-side before completing authentication and confirm that no session cookie is issued. These cases test controls the virtual authenticator cannot enforce because challenge ownership and replay state belong to the relying party.
Do not implement signature verification inside Playwright assertions. That duplicates security logic, creates false confidence, and makes the test suite another cryptographic implementation to maintain. Instead, assert server decisions and audit-safe error codes such as CHALLENGE_EXPIRED, ORIGIN_MISMATCH, or CREDENTIAL_REVOKED if those codes are part of your documented API contract.
Verify: Run the real authentication test with npx playwright test --project=chromium --trace=on. Confirm the trace shows one verification POST, a 200 response, and navigation to the authenticated page. Run one replay case and confirm it receives a 4xx response without a new authenticated cookie.
Best Practices
- Create one authenticator per test. Shared authenticators turn credential counts and sign counters into order-dependent assertions.
- Match capabilities to the product requirement. A platform passkey usually needs CTAP2,
internaltransport, resident keys, and user verification. Security-key coverage may useusbtransport and different residency expectations. - Keep server verification in scope. Browser success only proves that an assertion was created, not that your backend validated challenge, origin, RP ID, signature, flags, and replay protection.
- Generate a fresh challenge per ceremony. Fixed or reusable production challenges defeat WebAuthn's anti-replay design.
- Assert accessible UI states and recovery routes. Users need clear options when a credential is unavailable, revoked, or tied to another device.
- Separate emulator tests from a small manual hardware matrix. Virtual authenticators provide determinism, but they cannot validate operating-system sheets, Bluetooth handoff, device enrollment, or vendor-specific biometric UX.
- Treat traces as sensitive. Restrict retention, redact secrets, and do not attach raw authentication payloads to public tickets.
Interview Questions and Answers
Q: Why use CDP instead of mocking navigator.credentials?
CDP's WebAuthn virtual authenticator lets the browser execute its genuine WebAuthn implementation. A JavaScript mock can validate UI branching, but it bypasses option validation, credential storage, authenticator selection, and browser error behavior. Use mocks only for narrow component tests.
Q: Why is this suite Chromium-only?
Playwright exposes newCDPSession() for Chromium-based browsers. The commands used here belong to Chromium's WebAuthn DevTools Protocol domain, so the fixture is not portable to Firefox or WebKit. Keep cross-browser UI checks separate from virtual-authenticator ceremony tests.
Q: What is the difference between user presence and user verification?
User presence proves that a person interacted with the authenticator, commonly through a touch or confirmation. User verification establishes a stronger local check such as a biometric or PIN. A relying party requests the latter with userVerification: 'required'.
Q: Why inspect credentials after registration?
Inspection confirms that a credential was stored on the intended authenticator with the expected RP ID and residency. It catches false-positive application messages and incorrect authenticator selection. It also provides controlled setup evidence without asserting random key material.
Q: Does a successful browser assertion prove secure login?
No. The relying-party server must validate the challenge, origin, RP ID hash, signature, authenticator data flags, and session binding. End-to-end tests should assert both browser output and the server's safe success or rejection response.
Q: How should passkey tests be isolated?
Provision an authenticator inside a fixture for each test and remove it in teardown. Avoid serial dependence and never reuse credential counts between cases. Seed application users through an approved test API or fixture, then clean server state independently.
Troubleshooting
Problem: Protocol error (WebAuthn.enable): 'WebAuthn.enable' wasn't found -> Confirm the project launches bundled Chromium and that the CDP session is created from that page. Do not run this fixture under Firefox or WebKit. Update Playwright and reinstall its matching Chromium binary if versions drift.
Problem: Registration ends with NotAllowedError -> Check that automaticPresenceSimulation and isUserVerified are true. Also ensure hasResidentKey is true when the application requires a resident key, and verify the RP ID matches localhost.
Problem: The browser reports that WebAuthn requires a secure context -> Serve the test on http://localhost, not an arbitrary HTTP hostname or raw remote IP. Use trusted HTTPS for shared test environments.
Problem: WebAuthn.getCredentials returns an empty array -> Wait for the visible registration success state before inspecting the device. Confirm the application awaited navigator.credentials.create() and that you query the same authenticatorId created by the fixture.
Problem: The negative sign-in test waits for ten seconds -> Lower the application timeout in test environments or use WebAuthn.setAutomaticPresenceSimulation deliberately. Do not solve the delay with arbitrary Playwright sleeps, because they hide ceremony state.
Problem: Tests pass alone but fail as a suite -> Remove global authenticators and shared pages. Use fixture-scoped devices, preserve parallel-safe application users, and make teardown run even when an assertion fails.
Where To Go Next
Move the fixture into your framework, replace the demo with your registration and sign-in routes, and add assertions around the real verification endpoints. Extend coverage to duplicate registration, revoked credentials, changed RP IDs, expired challenges, replayed assertions, and account recovery.
Use how to test passkey authentication flows for a broader risk matrix, Playwright API request context examples for test-user setup, and Playwright accessibility automation for accessible error and recovery states. Practice explaining the design through Playwright coding interview questions, or apply the same suite-design skills in the QA practice area.
Conclusion
The reliable way to test webauthn passkeys with playwright typescript is to keep the browser ceremony real and virtualize only the authenticator. Chromium CDP gives you deterministic credential creation, verification behavior, state inspection, and cleanup without physical hardware.
Start with registration, authentication, and the two negative cases in this guide. Then connect them to server-side verification and a small manual device matrix so your coverage spans protocol correctness, application behavior, and the operating-system experiences automation cannot reproduce.
Interview Questions and Answers
How would you automate passkey testing with Playwright?
I would run Chromium, open a CDP session, enable the WebAuthn domain, and add a CTAP2 virtual authenticator. I would drive registration and authentication through accessible UI locators, then inspect authenticator credentials and assert server responses. Each test would receive a fresh authenticator that is removed in fixture teardown.
Why is a virtual authenticator better than mocking the WebAuthn API?
It preserves the browser's real implementation of `navigator.credentials.create()` and `navigator.credentials.get()`. That exercises option validation, authenticator matching, credential storage, and DOMException behavior. A mock only proves that application code reacts to a fabricated return value.
Which virtual authenticator options matter for platform passkeys?
I use CTAP2 with internal transport, resident-key support, and user-verification support. I set the current verification result and automatic presence explicitly so tests are deterministic. The exact capabilities must match what the relying party requests.
What should the server validate during WebAuthn authentication?
It must validate the session-bound challenge, expected origin, RP ID hash, assertion signature, and required authenticator flags. It should reject replay and apply appropriate credential and counter policies. A resolved browser promise alone does not establish a secure login.
How do you test user-verification failure?
First I register a credential with a verification-capable authenticator. Before authentication, I call `WebAuthn.setUserVerified` with false and submit a request that requires user verification. I assert the application's controlled rejection state and the absence of an authenticated server session.
How do you prevent flaky passkey tests?
I isolate one virtual authenticator and application user per test, await visible ceremony outcomes, and inspect the specific authenticator ID. I avoid fixed sleeps, generated credential-value assertions, and test ordering. Fixture teardown removes the device even after a failed assertion.
What remains in a manual passkey test matrix?
I keep operating-system prompts, real biometrics or PINs, security keys, synced credentials, and cross-device authentication in the manual matrix. I also sample account recovery and accessibility on supported devices. Automation handles deterministic protocol and application regression at higher frequency.
Frequently Asked Questions
Can Playwright test WebAuthn passkeys without physical hardware?
Yes. Chromium's CDP WebAuthn domain can attach a virtual CTAP2 authenticator with configurable resident-key and user-verification behavior. The page still uses the real Web Authentication API, but no fingerprint reader or security key is needed.
Can the same virtual authenticator tests run in Firefox and WebKit?
Not with this fixture. It depends on Playwright's Chromium-only CDP session and Chromium WebAuthn protocol commands. Run presentation and recovery UI checks cross-browser, while keeping ceremony emulation in a Chromium project.
Should I mock navigator.credentials in Playwright tests?
Mocking can help a small UI component test, but it should not replace ceremony coverage. A virtual authenticator exercises browser option validation, credential creation, storage, selection, and authentication behavior that a JavaScript stub bypasses.
How do I verify that a passkey was really created?
Assert the application's registration result, then call `WebAuthn.getCredentials` with the fixture's authenticator ID. Check stable fields such as credential count, RP ID, user name, and resident-credential status rather than generated key bytes.
Is localhost allowed for WebAuthn testing over HTTP?
Browsers treat localhost as a potentially trustworthy origin for development, so this tutorial works on `http://localhost`. Deployed environments must use a secure context, normally trusted HTTPS, and an RP ID compatible with the page origin.
Does a virtual authenticator replace manual passkey testing?
No. It provides fast, deterministic protocol and application coverage, but it cannot reproduce operating-system dialogs, biometric enrollment, cross-device handoff, Bluetooth behavior, or every vendor authenticator. Keep a focused manual hardware matrix.
What passkey failures should an automated suite cover?
Cover missing credentials, failed user verification, duplicate registration, revoked credentials, wrong RP IDs, expired challenges, replay attempts, and server rejection. Also verify that recovery paths remain secure and understandable.
Related Guides
- How to Test Browser Permissions With Playwright TypeScript (2026)
- How to Test GraphQL Subscriptions with Playwright (2026)
- How to Test Service Workers With Playwright (2026)
- How to Add CI to a test framework (2026)
- How to Add logging to a test framework (2026)
- How to Add reporting to a test framework (2026)