Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.authsignal.com/llms.txt

Use this file to discover all available pages before exploring further.

This guide shows how to write browser-based end-to-end tests that cover the full passkey lifecycle: enrolling a passkey, then using it to complete a challenge. The test drives your app exactly like a user would, letting it redirect into your identity provider and on to Authsignal. This exercises your policy and user-flow configuration alongside the passkey ceremony.

The challenge with passkey E2E tests

Passkeys are backed by hardware-bound or password-manager-synced credentials. A regular Playwright or Selenium test can’t unlock Touch ID, summon a security key, or open Apple Passwords. Chromium ships with the WebAuthn DevTools Protocol domain, which lets you install a virtual authenticator. Once installed, every navigator.credentials.create and navigator.credentials.get call resolves against that virtual authenticator instead of real platform hardware. Playwright exposes this via CDP sessions.

Prerequisites

  • An Authsignal tenant. We recommend a dedicated tenant for testing so test traffic doesn’t pollute production analytics.
  • The passkey authenticator enabled for the tenant.
  • An app integrated with either the pre-built UI, the Web SDK, or an identity provider integration.
  • Playwright installed in your project.

Setting up the virtual authenticator

The helper below enables the WebAuthn CDP domain and adds a platform-style virtual authenticator that supports resident keys and user verification. This is the closest analogue to a real platform authenticator like Touch ID.
utils/page-helpers.ts
import { Page } from "@playwright/test";

export async function setupWebAuthn(page: Page) {
  const client = await page.context().newCDPSession(page);

  await client.send("WebAuthn.enable", { enableUI: true });

  const result = await client.send("WebAuthn.addVirtualAuthenticator", {
    options: {
      protocol: "ctap2",
      transport: "internal",
      hasResidentKey: true,
      hasUserVerification: true,
      isUserVerified: true,
      automaticPresenceSimulation: true,
    },
  });

  return { client, authenticatorId: result.authenticatorId };
}
Key options:
  • transport: "internal" makes the credential behave like a platform authenticator. Use "usb" or "nfc" if you want to simulate a roaming security key.
  • hasResidentKey: true enables discoverable credentials so passkey autofill works.
  • isUserVerified: true and automaticPresenceSimulation: true let the test bypass biometric/touch prompts.

Provision a test user

Each test needs a known user in your identity provider. Stand this up as a fixture so it runs once per test suite:

Drive the journey end-to-end

Navigate to your app’s normal login URL and complete the user login via primary factor first. If your application is passwordless, this will just be providing the username. A realistic test covers two passes: an enrollment pass that creates the passkey, and a re-authentication pass that signs the same user back in using the credential just enrolled. The virtual authenticator persists for the life of the BrowserContext, so both passes share the same credential. Depending on your integration, this may either redirect to Authsignal’s pre-built UI or your custom UI which supports passkey enrollment and authentication.
tests/passkey.spec.ts
import { test, expect } from "@playwright/test";
import { setupWebAuthn } from "./utils/page-helpers";
import { provisionTestUser } from "./utils/idp-helpers";

test("user can enroll a passkey then sign back in with it", async ({ page }) => {
  // 1. Set up webAuthn and provision a test user
  const { client } = await setupWebAuthn(page);
  const testUser = await provisionTestUser();

  // 2. Navigate to your application's login page
  await page.goto("https://yourapp.com/login");

  // 3. Complete the login form and click the sign-in button
  await page.getByRole("textbox", { name: "Email Address" }).fill(testUser.email);
  await page.getByRole("textbox", { name: "Password" }).fill(testUser.password);
  await page.getByRole("button", { name: "Sign in" }).click();

  // 4. Wait for the virtual authenticator to create the passkey
  const credentialAdded = new Promise<void>((resolve) => {
    client.once("WebAuthn.credentialAdded", () => resolve());
  });

  // Depending on your integration and action configuration, you may need to customize this step to initiate the passkey creation prompt.
  // This example assumes the use of the Authsignal pre-built UI with an action configuration that has multiple authentication methods enabled.
  await page.getByText("Passkey", { exact: true }).click();

  await credentialAdded;

  // 5. The pre-built UI redirects back to your app and assert that the user is authenticated.
  await page.waitForURL(/yourapp\.com\/dashboard/);
  await expect(page.getByText(`Welcome, ${testUser.email}`)).toBeVisible();

  // 6. Sign out from your application to start a fresh journey on the next request
  await page.goto("https://yourapp.com/logout");

  // 7. Sign back in to begin the re-authentication process.
  await page.goto("https://yourapp.com/login");
  await page.getByLabel("Email Address").fill(testUser.email);
  await page.getByLabel("Password").fill(testUser.password);

  // 8. After clicking sign in, the user will be redirected to the Authsignal pre-built UI where they passkey prompt will be displayed.
  const credentialAsserted = new Promise<void>((resolve) => {
    client.once("WebAuthn.credentialAsserted", () => resolve());
  });
  await page.getByRole("button", { name: "Sign in" }).click();

  // Present the virtual authenticator credential to the browser
  await credentialAsserted;

  // 9. Wait for the pre-built UI to redirect back to your application and assert that the user is authenticated.
  await page.waitForURL(/yourapp\.com\/dashboard/);
  await expect(page.getByText(`Welcome, ${testUser.email}`)).toBeVisible();
});
The WebAuthn.credentialAdded and WebAuthn.credentialAsserted events fire when the virtual authenticator actually completes a ceremony, so they’re a reliable signal that the WebAuthn call resolved successfully before you make further DOM assertions.

Other test frameworks

This guide is written for Playwright, but the same virtual-authenticator approach works in any framework that speaks either the Chrome DevTools Protocol or the W3C WebAuthn Automation extension:
  • Selenium 4: first-class support via the W3C WebDriver extension. See Virtual Authenticator in the Selenium docs. Works across Chrome, Edge, and Firefox.
  • WebdriverIO: exposes the same WebDriver extension. See addVirtualAuthenticator.
  • Puppeteer: uses raw CDP, the same protocol Playwright uses under the hood. Open a CDPSession and call the WebAuthn domain directly.

Next steps