← Back to list

Playwright: Automating Passkey Login with WebAuthn

We are already familiar with simple login with username and password, but many services and applications are starting to use a newer…

Svetlana Tretjakova · 2026-07-29 08:54 · 2 claps · 3.4 min read
#playwright-automation #passkey #webauthn #test-automation #software-testing
Open on Medium ↗

Playwright: Automating Passkey Login with WebAuthn

We are already familiar with simple login with username and password, but many services and applications are starting to use a newer approach -passkey. Recently I logged into my PlayStation account, and found out that they also offer passkey login.

So, it’s very important to know how to cover login with passkey with end-to-end tests, because, obviously, Playwright doesn’t have its own finger with fingerprint.

Let’s dive into setup of reusable login with passkey.

Chromium gives us another option instead of necessity of giving your finger to playwright: the Chrome DevTools Protocol (CDP) and its WebAuthn API.

By the end of this tutorial, our flow will look like this:

Playwright → CDP session → virtual authenticator → credential → WebAuthn authentication → logged-in application

Manually, login with passkey is easy, you go to page, enter username, press your finger and voila you are logged in.

In automation it becomes interesting.

A regular Playwright test cannot physically interact with Touch ID, Face ID, Windows Hello, or a security key.

But we do not actually need a physical authenticator. Chromium allows us to create a virtual WebAuthn authenticator.

Step 1: Create a CDP session

First, we need access to Chrome DevTools Protocol.

In Playwright, we can create a CDP session directly from the browser context:

import { CDPSession, Page } from '@playwright/test';

export interface PasskeyClient {
  client: CDPSession;
  authenticatorId: string;
}

export async function createPasskeyClient(
  page: Page
): Promise<PasskeyClient> {
  const client = await page.context().newCDPSession(page);

  await client.send('WebAuthn.enable');

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

  return {
    client,
    authenticatorId: result.authenticatorId
  };
}

First:

await client.send('WebAuthn.enable');

This enables the WebAuthn domain for our CDP session.

Then:

WebAuthn.addVirtualAuthenticator

creates the authenticator that will behave like a real passkey-capable device.

It exists entirely inside our automated browser session, so no physical security key is required.

Step 2: Add a credential

Now we need to add a credential to our virtual authenticator.

We can add one through CDP:

import { CDPSession } from '@playwright/test';
import { Protocol } from 'playwright-core/types/protocol';
export async function addCredential(
  client: CDPSession,
  authenticatorId: string,
  credential: Protocol.WebAuthn.Credential
) {
  await client.send('WebAuthn.addCredential', {
    authenticatorId,
    credential
  });
}

The credential contains information such as:

{
  credentialId,
  isResidentCredential,
  rpId,
  privateKey,
  userHandle,
  signCount,
  backupEligibility,
  backupState
}

rpId identifies the relying party the credential belongs to. The private key is used by the authenticator during authentication.

And obviously: do not hardcode a real private key into your test repository.❤

For CI, credentials should come from your secret-management solution or protected environment variables.

For example:

const encodedSecret = process.env.PASSKEY_SECRET;
if (!encodedSecret) {
  throw new Error('Missing PASSKEY_SECRET');
}
const credential = JSON.parse(
  Buffer.from(encodedSecret, 'base64').toString('utf-8')
);

We can then register it:

await addCredential(
  passkey.client,
  passkey.authenticatorId,
  {
    credentialId: credential.credentialId,
    isResidentCredential: credential.isResidentCredential,
    rpId: credential.rpId,
    privateKey: credential.privateKey.replace(/\s+/g, ''),
    userHandle: credential.userHandle,
    signCount: credential.signCount,
    backupEligibility: credential.backupEligibility,
    backupState: credential.backupState
  }
);

Step 3: Start the normal login flow

Now we can continue with the normal login flow:

await page.goto('https://example.com');
await page.locator('#username').fill(username);
await page.locator('#continueButton').click();

Eventually, the identity provider asks the browser for a WebAuthn credential. And now we have one.

Step 4: Trigger passkey authentication

Suppose the authentication page contains:

const passkeyButton = page.getByLabel(
  'Use passkey',
  { exact: true }
);
await passkeyButton.click();

Normally, clicking this button would trigger something like:

navigator.credentials.get(...)

and the browser would ask the user to authenticate using their device.

But our browser already has a virtual authenticator attached to it.

The WebAuthn request can be handled by the credential we injected through CDP.

This is the difference between mocking authentication and automating authentication. We are not mocking the login or skipping authentication in the test environment. The real WebAuthn flow still happens — we just use a virtual authenticator instead of a physical one.

Step 5: Verify authentication

After the WebAuthn ceremony completes, wait for the authenticated destination:

await page.waitForURL('**/dashboard');
await expect(page).toHaveURL(/dashboard/);

And that is it. Our Playwright test has authenticated using a passkey.

No password.

No OTP.

No physical security key.

No finger required.

Now let’s move the flow into a reusable helper:

import { expect, Page } from '@playwright/test';

export async function loginWithPasskey(
  page: Page,
  username: string
) {
  const passkey = await createPasskeyClient(page);
  await page.goto('https://example.com');
  await page.locator('#username').fill(username);
  await page.locator('#continueButton').click();
  const encodedSecret = process.env.PASSKEY_SECRET;
  if (!encodedSecret) {
    throw new Error('Missing PASSKEY_SECRET');
  }
  const credential = JSON.parse(
    Buffer.from(encodedSecret, 'base64').toString('utf-8')
  );
  await addCredential(
    passkey.client,
    passkey.authenticatorId,
    {
      credentialId: credential.credentialId,
      isResidentCredential: credential.isResidentCredential,
      rpId: credential.rpId,
      privateKey: credential.privateKey.replace(/\s+/g, ''),
      userHandle: credential.userHandle,
      signCount: credential.signCount,
      backupEligibility: credential.backupEligibility,
      backupState: credential.backupState
    }
  );
  await page
    .getByLabel('Use passkey', { exact: true })
    .click();
  await page.waitForURL('**/dashboard');
  await expect(page).toHaveURL(/dashboard/);
}

And our test becomes surprisingly small:

import { test } from '@playwright/test';
import { loginWithPasskey } from './auth';

test('user can authenticate with passkey', async ({ page }) => {
  await loginWithPasskey(
    page,
    'test-user@example.com'
  );
});

Final thoughts

Modern authentication creates interesting challenges for E2E automation.

Passkeys are a good example because the first reaction is often:

How am I supposed to automate biometrics in CI?

The answer is: you don’t.

With Playwright, Chrome DevTools Protocol and the WebAuthn API, we can create a virtual authenticator, provide it with a credential and let the application execute its real authentication flow. Our CI pipeline does not need Touch ID. Which is probably good.

I still haven’t figured out where GitLab keeps its fingers. ❤


메타데이터
post_id
dababfbdf63a
slug
playwright-automating-passkey-login-with-webauthn-dababfbdf63a
url
https://medium.com/@svetlana.tretjakova/playwright-automating-passkey-login-with-webauthn-dababfbdf63a
canonical_url
https://medium.com/@svetlana.tretjakova/playwright-automating-passkey-login-with-webauthn-dababfbdf63a
author_url
https://medium.com/@svetlana.tretjakova
status
ok
fetched_at
2026-08-19 03:50:58