
Recommendation. Use Playwright 1.61's new browserContext.credentials virtual authenticator for Safari/WebKit passkey tests — it works cross-browser without CDP and runs fine on Figment's $29 virtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk; Linux/KVM, no GPU, no audio). Request a physical M-series Mac from $95/month only when your test needs the real Secure Enclave and Touch ID on a platform authenticator. Start the request at /mac-hosting/start. Apple's own platform-authenticator reference is the primary source for what Safari delegates to the OS.
Safari's platform authenticator on macOS is the device itself — Touch ID on M-series Macs, backed by the Secure Enclave. When a page calls navigator.credentials.create({ publicKey: { authenticatorSelection: { authenticatorAttachment: "platform" } } }), Safari delegates to the OS passkey provider documented in Apple's ASAuthorizationPlatformPublicKeyCredentialProvider reference. Confirm the minimum macOS/Safari pair you support against the current Safari release notes for the exact builds in your matrix; passkey behavior and UI have changed across releases.
For automated CI there are two separate questions:
Only (2) needs a physical Mac. (1) runs happily on a virtual authenticator.
The older Playwright pattern used the Chrome DevTools Protocol method WebAuthn.addVirtualAuthenticator via a CDPSession. That protocol only exists in Chromium. Point it at WebKit and there is no CDP endpoint to attach to — the call cannot land. This is a protocol limitation, not a configuration one, so any project that relies on CDP for passkey tests gets zero Safari coverage.
Playwright 1.61 shipped a first-class virtual authenticator on browserContext.credentials that works in Chromium, Firefox, and WebKit without CDP. The Credentials API exposes four methods: create(rpId, options?) to seed a credential, install() to intercept navigator.credentials.create()/get(), get(options?) to read back everything the authenticator holds, and delete(id) to remove one.
Install Playwright 1.61 or newer and the WebKit browser:
npm install -D @playwright/test@^1.61
npx playwright install webkit
Call install() before any page script touches WebAuthn, then let the app drive registration:
// passkey-register.spec.ts
import { test, expect, webkit } from '@playwright/test';
test('WebKit: registers a passkey and returns a non-empty credentialId', async () => {
const browser = await webkit.launch();
const context = await browser.newContext();
// Intercept navigator.credentials.create/get before any page loads.
await context.credentials.install();
const page = await context.newPage();
await page.goto('http://localhost:3000/register');
await page.getByRole('button', { name: 'Register passkey' }).click();
// The app renders the returned credentialId after a successful ceremony.
const credentialIdEl = page.locator('[data-testid="credential-id"]');
await expect(credentialIdEl).toBeVisible({ timeout: 8000 });
const credentialId = (await credentialIdEl.textContent())?.trim() ?? '';
expect(credentialId.length).toBeGreaterThan(20);
// Read the credential back for later specs via a JSON fixture (not env vars,
// which do not cross spec/worker boundaries).
const stored = await context.credentials.get({ rpId: 'localhost' });
await require('node:fs/promises').writeFile(
'test-fixtures/passkey.json',
JSON.stringify(stored, null, 2),
);
await browser.close();
});
Seed the saved credential, call install(), run the assertion flow, and verify the response against your own @simplewebauthn/server relying party:
// passkey-assert.spec.ts
import { test, expect, webkit } from '@playwright/test';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { readFile } from 'node:fs/promises';
test('WebKit: assertion verifies with @simplewebauthn/server', async () => {
const [stored] = JSON.parse(await readFile('test-fixtures/passkey.json', 'utf8'));
const browser = await webkit.launch();
const context = await browser.newContext();
// Re-seed the exact credential the registration step produced.
await context.credentials.create(stored.rpId, {
id: stored.id,
userHandle: stored.userHandle,
privateKey: stored.privateKey,
publicKey: stored.publicKey,
signCount: stored.signCount,
});
await context.credentials.install();
const page = await context.newPage();
await page.goto('http://localhost:3000/login');
// Capture the challenge your server issued for this attempt, then let the
// app drive navigator.credentials.get().
const expectedChallenge = await page.getAttribute('[data-challenge]', 'data-value');
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
const assertionResponse = await page.evaluate(
() => (window as any).__lastAssertionResponse,
);
expect(assertionResponse?.id).toBeTruthy();
const verification = await verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge: expectedChallenge!,
expectedOrigin: 'http://localhost:3000',
expectedRPID: 'localhost',
authenticator: {
credentialID: Buffer.from(stored.id, 'base64url'),
credentialPublicKey: Buffer.from(stored.publicKey, 'base64url'),
counter: stored.signCount ?? 0,
},
});
expect(verification.verified).toBe(true);
await browser.close();
});
The code is an example, not a measured benchmark — run it against your own routes, your own challenge issuer, and the exact @simplewebauthn/server version you deploy. Keep any real keys in your CI secret manager; never export the full shell environment to a shared file, and never commit private keys to the repo.
The virtual authenticator answers the ceremony correctly, but it does not exercise the Secure Enclave, the Touch ID sensor, or the macOS Keychain's passkey storage. If your acceptance criteria include "the system Touch ID sheet renders", "a cancelled biometric returns NotAllowedError", or "a passkey synced via iCloud Keychain is offered", those paths require a Mac with a Secure Enclave and a Touch ID sensor. The system sheet is driven by the OS itself and cannot be scripted from inside the browser process.
UI automation at that layer is manual or driven with an accessibility tool that can tap the OS prompt — not Playwright.
| Test type | Minimum host | Figment plan |
|---|---|---|
| Virtual-authenticator Playwright runs (WebKit/Chromium/Firefox) | macOS + Node 20 | $29 virtual Intel macOS |
| Intel Xcode build/sign of a web view host app (Xcode 26 or earlier per Apple's porting guide) | Intel-compatible toolchain | $29 virtual Intel macOS |
| Real Touch ID / Secure Enclave passkey tests | Physical M-series Mac | from $95/month (exact chip, RAM and disk confirmed in the quote) |
Physical i3 ($49) and M-series plans are request-based while supply is confirmed, so request a configuration and state the Secure Enclave requirement in the ticket. The $29 VM is not Apple silicon and has no GPU or audio; it will not satisfy a Touch ID test no matter how fast it is.
Price sanity-check against the market. CloudMac publishes an M4/16 GB/256 GB plan at $99.99/month, and its home page advertises a $999/year option — about $83.25/month effective, roughly $141 less per year than twelve months of Figment's $95 entry price. CloudMac's 512 GB and 1 TB rows are listed as fully reserved as of 2026-11-10, so availability depends on the row you need. MacStadium lists M2.S at $109 (8 GB/256 GB), M4.S at $149 (16 GB/256 GB), and M4.M at $249 (24 GB/512 GB), with support and a 99.99% uptime claim included. Figment's $95 entry is a lower starting price for an unspecified M-series configuration, not a like-for-like M4/16 GB claim — ask for the exact chip in the quote before comparing dollar-for-dollar. Managed Build Mac at $249/month adds one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support; Managed Mac Team at $999/month adds a second Mac, two CI runners, team setup, backups, monitoring and one hour of monthly support.
Request a quote at /mac-hosting or start the configuration at /mac-hosting/start.
Related guides: