FIDO2 Authenticator Testing: Strategies for Hardware and Software Keys
FIDO2 is the authentication standard that powers WebAuthn and passkeys. It encompasses two layers: WebAuthn (the browser API) and CTAP2 (the Client-to-Authenticator Protocol). Testing FIDO2 authenticators — hardware security keys like YubiKey, software authenticators, and platform authenticators — requires strategies for both the protocol layer and the application layer. This guide covers all of them.
FIDO2 Architecture and What Needs Testing
FIDO2 has three components:
- Authenticator — the hardware key or platform biometric (Touch ID, Windows Hello)
- Client — the browser that mediates between authenticator and server
- Relying Party (RP) — your server that validates credentials
Each component introduces testing requirements:
| Layer | What can go wrong | Testing approach |
|---|---|---|
| Authenticator | Wrong attestation format, UV failure | Virtual authenticator, emulators |
| Client | Incorrect challenge handling, origin mismatch | Browser automation with CDP |
| RP | Signature verification failure, replay attacks | Unit tests with real crypto libraries |
Testing Hardware Security Keys (Without Physical Hardware)
Physical YubiKeys and similar devices can't be automated. The solutions:
SoftHSM + OpenSC Emulation
For server-side CTAP2 testing, software HSMs can simulate authenticator responses:
# Install SoftHSM for Linux CI
<span class="hljs-built_in">sudo apt-get install softhsm2
<span class="hljs-comment"># Initialize a token
softhsm2-util --init-token --slot 0 --label <span class="hljs-string">"test-token" \
--pin 1234 --so-pin 5678This lets you test PKCS#11 integration without physical hardware.
Chrome DevTools Protocol Virtual Authenticator
For browser-level FIDO2 testing, Playwright's CDP interface provides the most complete simulation:
const { chromium } = require('@playwright/test');
async function setupFIDO2Authenticator(page, options = {}) {
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
const authenticatorOptions = {
protocol: options.protocol || 'ctap2',
ctap2Version: options.ctap2Version || 'ctap2_1',
transport: options.transport || 'usb',
hasResidentKey: options.hasResidentKey !== false,
hasUserVerification: options.hasUserVerification !== false,
hasLargeBlob: options.hasLargeBlob || false,
hasCredBlob: options.hasCredBlob || false,
hasMinPinLength: options.hasMinPinLength || false,
automaticPresenceSimulation: true,
isUserVerified: options.isUserVerified !== false,
};
const { authenticatorId } = await client.send(
'WebAuthn.addVirtualAuthenticator',
{ options: authenticatorOptions }
);
return { client, authenticatorId };
}Testing Different Authenticator Types
FIDO2 supports multiple transports. Test each:
describe('Authenticator transport support', () => {
const transports = ['usb', 'nfc', 'ble', 'internal'];
for (const transport of transports) {
it(`registers credential via ${transport} transport`, async ({ page }) => {
const { client } = await setupFIDO2Authenticator(page, { transport });
await page.goto('/register');
await page.click('[data-testid="register-security-key"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});
}
});Testing CTAP2 Protocol Behaviors
CTAP2 has specific protocol behaviors that affect security. Test the edge cases.
Resident Keys (Discoverable Credentials)
Resident keys (also called discoverable credentials or passkeys) are stored on the authenticator itself. They enable usernameless login:
test('usernameless login with resident credential', async ({ page }) => {
const { client } = await setupFIDO2Authenticator(page, {
hasResidentKey: true,
transport: 'internal',
});
// Register with resident key
await page.goto('/register');
await page.fill('[name="username"]', 'user@example.com');
await page.click('[data-testid="create-passkey"]');
await expect(page.locator('[data-testid="passkey-created"]')).toBeVisible();
// Authenticate without entering username
await page.goto('/login');
// Don't fill username — test usernameless flow
await page.click('[data-testid="use-passkey"]');
// Virtual authenticator auto-selects the resident credential
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});
test('non-resident credentials require username hint', async ({ page }) => {
await setupFIDO2Authenticator(page, {
hasResidentKey: false, // server-side credential
transport: 'usb',
});
await page.goto('/login');
// Without entering username first, passkey flow should prompt for it
await page.click('[data-testid="use-passkey"]');
await expect(page.locator('[data-testid="username-prompt"]')).toBeVisible();
});User Verification (UV) Requirements
User verification (biometric or PIN) can be required or optional:
describe('User verification requirements', () => {
it('succeeds when UV is required and authenticator supports it', async ({ page }) => {
await setupFIDO2Authenticator(page, {
hasUserVerification: true,
isUserVerified: true,
});
// App requires UV for sensitive operations
await page.goto('/account/delete');
await page.click('[data-testid="verify-identity"]');
await expect(page.locator('[data-testid="identity-verified"]')).toBeVisible();
});
it('rejects when UV required but not performed', async ({ page }) => {
await setupFIDO2Authenticator(page, {
hasUserVerification: true,
isUserVerified: false, // UV not performed
});
await page.goto('/account/delete');
await page.click('[data-testid="verify-identity"]');
// Should reject — UV was required but not performed
await expect(page.locator('[data-testid="verification-failed"]')).toBeVisible();
});
});Server-Side FIDO2 Validation Tests
The server validates FIDO2 attestation objects and assertion signatures. These need unit tests with real cryptographic operations.
Testing Attestation Verification
# Using python-fido2 library
from fido2.server import Fido2Server
from fido2.webauthn import PublicKeyCredentialRpEntity
rp = PublicKeyCredentialRpEntity("example.com", "Example Corp")
server = Fido2Server(rp)
def test_valid_attestation_passes():
"""Test that valid CTAP2 attestation is accepted"""
state, options = server.register_begin(user_info)
# Simulate authenticator response (using test fixtures)
credential = create_test_credential(options, private_key=TEST_PRIVATE_KEY)
auth_data, _ = server.register_complete(state, credential)
assert auth_data.credential_data is not None
assert auth_data.credential_data.aaguid is not None
def test_wrong_origin_rejected():
"""Test that credentials from wrong origin are rejected"""
state, options = server.register_begin(user_info)
# Credential signed with wrong origin
credential = create_test_credential(
options,
origin="https://malicious.com", # wrong origin
private_key=TEST_PRIVATE_KEY
)
with pytest.raises(ValueError, match="origin"):
server.register_complete(state, credential)
def test_replay_attack_rejected():
"""Test that replaying a credential response is rejected"""
state, options = server.register_begin(user_info)
credential = create_test_credential(options, private_key=TEST_PRIVATE_KEY)
# First use succeeds
server.register_complete(state, credential)
# Replay is rejected (challenge already used)
with pytest.raises(ValueError):
server.register_complete(state, credential)Testing Counter Validation
FIDO2 authenticators maintain a signature counter to detect cloned keys:
def test_authenticator_clone_detection():
"""Test that signature counter regression is detected"""
# Authenticate once — counter = 1
login_success = authenticate_user(user_id, counter=1)
assert login_success
# Replay with same counter — should be rejected (possible clone)
login_duplicate = authenticate_user(user_id, counter=1)
assert not login_duplicate
# Replay with lower counter — should be rejected
login_regressed = authenticate_user(user_id, counter=0)
assert not login_regressedTesting Multiple Authenticators Per User
Users may register multiple security keys (primary + backup). Test this:
test('user can register and use multiple authenticators', async ({ page }) => {
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable', { enableUI: false });
// Register first authenticator
const { authenticatorId: auth1 } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: { protocol: 'ctap2', transport: 'usb', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true }
});
await page.goto('/settings/security');
await page.click('[data-testid="add-security-key"]');
await expect(page.locator('[data-testid="key-1"]')).toBeVisible();
// Remove first authenticator, add second
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId: auth1 });
const { authenticatorId: auth2 } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: { protocol: 'ctap2', transport: 'nfc', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true }
});
await page.click('[data-testid="add-security-key"]');
await expect(page.locator('[data-testid="key-2"]')).toBeVisible();
// Verify both keys appear in the list
const keyCount = await page.locator('[data-testid^="key-"]').count();
expect(keyCount).toBe(2);
// Authenticate with second key
await page.goto('/login');
await page.click('[data-testid="use-passkey"]');
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});Attestation Format Testing
FIDO2 supports multiple attestation formats. Test that your RP handles all of them:
ATTESTATION_FORMATS = ['packed', 'tpm', 'android-key', 'fido-u2f', 'none', 'apple']
@pytest.mark.parametrize('fmt', ATTESTATION_FORMATS)
def test_attestation_format_accepted(fmt):
"""Test each attestation format is handled correctly"""
credential = create_credential_with_attestation_format(fmt)
# For 'none' format, verification should succeed without attestation validation
# For other formats, behavior depends on RP's attestation requirement level
result = rp_server.verify_registration(credential)
# At minimum, 'none' attestation must always succeed
if fmt == 'none':
assert result.verifiedCI/CD Integration for FIDO2 Tests
# .github/workflows/fido2-tests.yml
name: FIDO2 Tests
jobs:
browser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Playwright
run: npx playwright install chromium --with-deps
- name: Run FIDO2 browser tests
run: npx playwright test tests/fido2/
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Python deps
run: pip install fido2 pytest
- name: Run server-side FIDO2 tests
run: pytest tests/server/fido2/Summary
FIDO2 testing requires: virtual authenticators (CDP) for browser-level tests covering resident keys, UV requirements, and transport types; server-side unit tests for attestation verification, origin validation, and replay attack prevention; counter validation tests for clone detection; multiple authenticator tests for backup key scenarios; and parametrized attestation format tests. The most critical tests are the server-side ones — a broken signature verifier silently accepts invalid credentials or rejects valid ones, either of which is a security or usability disaster.