The current E2E tests use Chrome DevTools Protocol (CDP) virtual authenticators, which have critical limitations:
- PRF Extension Bug: CDP virtual authenticators report
hasPrf=truebut return empty PRF results, requiring JavaScript mocking - No Real Browser Stack: Tests don't exercise the actual browser → platform authenticator → OS interaction
- No OS Dialogs: Real users see biometric prompts, passkey pickers, etc. that are completely bypassed
- Credential Storage: Virtual credentials don't persist like real passkeys do
- Edge Cases Missed: The bugs we fixed (userHandle extraction, discoverable login flow) weren't caught by mocked tests
We propose a three-tier testing strategy:
- Pure JavaScript/TypeScript unit tests for WebAuthn client logic
- Use
virtualwebauthnGo library patterns for mocking server-side - Run in CI on every commit
- Run Playwright in headed mode with software authenticator
- Uses
soft-fido2UHID virtual authenticator on Linux - Uses Chrome's
--enable-features=WebAuthenticationMacPlatformAuthenticatoron macOS - Uses Windows Hello software mode on Windows
- Tests real WebAuthn browser stack without hardware
- Run in CI with display server (Xvfb on Linux)
- Real passkey devices (YubiKey, security keys)
- Real platform authenticators (Touch ID, Windows Hello, Android)
- Exploratory testing before releases
On Linux, we use soft-fido2 to create a UHID virtual FIDO2 authenticator. This appears to the browser as a real USB security key, providing complete CTAP2 protocol support including:
- Discoverable credentials (resident keys)
- User verification (auto-approved for testing)
- hmac-secret extension (for WebAuthn PRF)
- Full credential management
-
Install UHID kernel module and permissions:
sudo modprobe uhid sudo usermod -a -G fido $USER echo 'KERNEL=="uhid", GROUP="fido", MODE="0660"' | sudo tee /etc/udev/rules.d/90-uhid.rules sudo udevadm control --reload-rules # Log out and back in for group membership to take effect
-
Build soft-fido2:
cd /path/to/soft-fido2 cargo build --release -p soft-fido2 --example virtual_authenticator
The test environment automatically manages soft-fido2 when SOFT_FIDO2_PATH is set:
# Start all services including soft-fido2
SOFT_FIDO2_PATH=/path/to/soft-fido2 \
FRONTEND_PATH=/path/to/wallet-frontend \
BACKEND_PATH=/path/to/go-wallet-backend \
make up
# Check status (shows soft-fido2 status)
make status
# Run real WebAuthn tests
make test-real-webauthn
# Stop everything including soft-fido2
make downYou can also manage soft-fido2 separately:
# Start soft-fido2 only
SOFT_FIDO2_PATH=/path/to/soft-fido2 make start-soft-fido2
# Stop soft-fido2 only
make stop-soft-fido2
# View soft-fido2 logs
tail -f /tmp/soft-fido2.logscripts/start-soft-fido2.shstarts the virtual authenticator as a background daemon- The authenticator creates a UHID device that appears as USB to the OS
- Chrome/Chromium detects it as an external security key
- The authenticator auto-approves all user presence/verification requests
- Credentials are stored in memory (ephemeral per test run)
Chrome has built-in support for software platform authenticators that don't require biometrics:
# macOS - Enable software authenticator (no Touch ID required)
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--enable-features=WebAuthenticationMacPlatformAuthenticator
# Or use Chrome's virtual authenticator UI feature for testing
# This creates a REAL software authenticator that appears to the browser
# as a platform authenticator// playwright.real-webauthn.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs/real-webauthn',
fullyParallel: false, // Serial execution for WebAuthn state consistency
workers: 1,
use: {
headless: false, // CRITICAL: Must run headed for real WebAuthn
launchOptions: {
args: [
// Enable Chrome's software authenticator
'--enable-features=WebAuthenticationCable',
// Allow insecure localhost for development
'--ignore-certificate-errors',
],
},
video: 'on-first-retry',
trace: 'on-first-retry',
},
projects: [
{
name: 'real-webauthn-chrome',
use: { ...devices['Desktop Chrome'] },
},
],
});// helpers/real-webauthn.ts
import { Page, BrowserContext } from '@playwright/test';
export interface RealWebAuthnOptions {
/**
* Whether to use Chrome's built-in software authenticator
* instead of the CDP virtual authenticator
*/
useSoftwareAuthenticator: boolean;
/**
* Timeout for user verification prompts
* Real authenticators show UI that needs time
*/
userVerificationTimeout: number;
}
export class RealWebAuthnHelper {
private page: Page;
private context: BrowserContext;
constructor(page: Page) {
this.page = page;
this.context = page.context();
}
/**
* Wait for and handle the passkey prompt.
* In headed mode with software authenticator, this may show a UI dialog.
*/
async waitForPasskeyPrompt(timeout = 30000): Promise<void> {
// The browser will show a native passkey dialog
// For software authenticator, it auto-approves after a brief delay
// For real testing, we may need to interact with the dialog
// Wait for the WebAuthn operation to complete or timeout
await this.page.waitForFunction(
() => {
const pendingOp = (window as any).__webauthn_pending__;
return pendingOp === undefined || pendingOp === null;
},
{ timeout }
);
}
/**
* Register tracking for WebAuthn operations.
* Injects minimal tracking code to know when operations start/complete.
*/
async trackWebAuthnOperations(): Promise<void> {
await this.page.addInitScript(() => {
const originalCreate = navigator.credentials.create.bind(navigator.credentials);
const originalGet = navigator.credentials.get.bind(navigator.credentials);
navigator.credentials.create = async (options) => {
(window as any).__webauthn_pending__ = 'create';
(window as any).__webauthn_start__ = Date.now();
try {
const result = await originalCreate(options);
(window as any).__webauthn_pending__ = null;
return result;
} catch (e) {
(window as any).__webauthn_pending__ = null;
throw e;
}
};
navigator.credentials.get = async (options) => {
(window as any).__webauthn_pending__ = 'get';
(window as any).__webauthn_start__ = Date.now();
try {
const result = await originalGet(options);
(window as any).__webauthn_pending__ = null;
return result;
} catch (e) {
(window as any).__webauthn_pending__ = null;
throw e;
}
};
});
}
}Puppeteer provides a higher-level authenticator API that might work better:
// Using Puppeteer instead of Playwright for WebAuthn
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: false,
args: ['--enable-features=WebAuthenticationCable'],
});
const page = await browser.newPage();
const client = await page.target().createCDPSession();
// This creates a virtual authenticator, but with full PRF support
await client.send('WebAuthn.enable');
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
ctap2Version: 'ctap2_1',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
// PRF support - note: may still have issues
extensions: ['prf', 'largeBlob', 'credBlob'],
},
});- Create new config
playwright.real-webauthn.config.ts - Create new test directory
specs/real-webauthn/ - Create
RealWebAuthnHelperclass - Port
critical-path.spec.tsto real WebAuthn tests - Add npm script:
test:real-webauthn
- Set up GitHub Actions workflow with Xvfb for headed browser
- Add caching for Chromium installation
- Run real WebAuthn tests as part of CI (separate job)
- Configure timeout and retry strategies
- Create tests that specifically exercise PRF extension
- Verify key derivation works correctly
- Test PRF output consistency across login flows
- Test PRF with different credential types
- Create comprehensive multi-tenant test suite
- Test userHandle extraction from different login paths
- Test tenant switching scenarios
- Test edge cases that CDP mocking missed
wallet-e2e-tests/
├── playwright.config.ts # Existing CDP-based config
├── playwright.real-webauthn.config.ts # New real WebAuthn config
├── helpers/
│ ├── webauthn.ts # Existing CDP helper
│ └── real-webauthn.ts # New real WebAuthn helper
├── specs/
│ ├── multi-tenancy/ # Existing CDP-based tests
│ └── real-webauthn/ # New real WebAuthn tests
│ ├── passkey-registration.spec.ts
│ ├── passkey-login.spec.ts
│ ├── prf-extension.spec.ts
│ └── multi-tenant-login.spec.ts
├── package.json
└── REAL_WEBAUTHN_TESTING.md # This document
{
"scripts": {
"test": "playwright test --config=playwright.config.ts",
"test:real-webauthn": "playwright test --config=playwright.real-webauthn.config.ts",
"test:all": "npm run test && npm run test:real-webauthn"
}
}- Chrome's CDP virtual authenticator returns empty PRF results
- Current workaround: JavaScript injection to mock PRF
- Real browser software authenticator should work correctly
- Requires Xvfb or similar display server
- Slower than headless tests
- May have flakiness due to UI timing
- Safari has limited automation for passkeys
- Firefox WebAuthn CDP support differs from Chrome
- Initial focus on Chrome/Chromium only
- macOS: Software authenticator available via feature flag
- Windows: Windows Hello software mode
- Linux: May require additional configuration
The new test framework should:
- ✅ Catch bugs like the
userHandleextraction issue we fixed - ✅ Test real PRF extension functionality
- ✅ Exercise the full browser WebAuthn stack
- ✅ Run in CI without manual intervention
- ✅ Provide clear failure diagnostics
- ✅ Be maintainable alongside existing tests
- WebAuthn.io - Demo and testing site
- passkeys.dev - Passkey implementation guide
- Chrome DevTools WebAuthn - CDP documentation
- Playwright Authentication - Playwright auth patterns
- virtualwebauthn - Go library for server-side testing