Virtual Authenticator (WebAuthn and Passkeys)
Test passwordless and two-factor flows by emulating a FIDO2/WebAuthn authenticator: create it, add resident and non-resident credentials, simulate user verification, and clean up.
Passkeys and security keys are replacing passwords, and they present a problem for automation: the browser pops a native dialog and asks for a fingerprint or a hardware key. Selenium 4 solves this with the W3C WebAuthn virtual authenticator API. You add a fake authenticator to the session, it answers registration and login challenges automatically, and no dialog appears. It works in Chromium browsers and Firefox; Safari does not implement it yet.
Concepts in One Minute
- Relying party (RP): your web app, identified by its domain (
rpId). - Authenticator: the device that holds keys. Virtual ones are configured with a protocol (
ctap2for passkeys,u2ffor legacy security keys) and a transport (internalfor platform authenticators like Touch ID,usb,nfc,ble). - Credential: a key pair for one user at one RP. Resident (discoverable) credentials are stored on the authenticator and enable username-less login; non-resident credentials require the RP to send the credential id.
- User verification (UV): the fingerprint or PIN step. Virtual authenticators can be told to always pass it.
Create a Virtual Authenticator
import org.openqa.selenium.virtualauthenticator.*;
VirtualAuthenticatorOptions options = new VirtualAuthenticatorOptions() .setProtocol(VirtualAuthenticatorOptions.Protocol.CTAP2) .setTransport(VirtualAuthenticatorOptions.Transport.INTERNAL) .setHasResidentKey(true) .setHasUserVerification(true) .setIsUserVerified(true); // fingerprint always "succeeds"
VirtualAuthenticator authenticator = ((HasVirtualAuthenticator) driver).addVirtualAuthenticator(options);from selenium.webdriver.common.virtual_authenticator import ( VirtualAuthenticatorOptions, Credential, Transport, Protocol)
options = VirtualAuthenticatorOptions()options.protocol = Protocol.CTAP2options.transport = Transport.INTERNALoptions.has_resident_key = Trueoptions.has_user_verification = Trueoptions.is_user_verified = True
driver.add_virtual_authenticator(options)const { VirtualAuthenticatorOptions, Credential, Transport, Protocol } =require('selenium-webdriver/lib/virtual_authenticator');
const options = new VirtualAuthenticatorOptions();options.setProtocol(Protocol['CTAP2']);options.setTransport(Transport['INTERNAL']);options.setHasResidentKey(true);options.setHasUserVerification(true);options.setIsUserVerified(true);
await driver.addVirtualAuthenticator(options);using OpenQA.Selenium.VirtualAuth;
var options = new VirtualAuthenticatorOptions() .SetProtocol(VirtualAuthenticatorOptions.Protocol.CTAP2) .SetTransport(VirtualAuthenticatorOptions.Transport.INTERNAL) .SetHasResidentKey(true) .SetHasUserVerification(true) .SetIsUserVerified(true);
string authenticatorId = ((WebDriver)driver).AddVirtualAuthenticator(options);Registration Flow (Sign Up With a Passkey)
With the authenticator attached, drive the UI as a user would. The browser’s WebAuthn prompt is answered by the virtual device, so the test only clicks and asserts.
driver.get("https://app.example.com/account/security");driver.findElement(By.id("add-passkey")).click();
// No dialog: the virtual authenticator completes navigator.credentials.create()new WebDriverWait(driver, Duration.ofSeconds(10)) .until(ExpectedConditions.textToBePresentInElementLocated(By.id("passkey-status"), "Passkey added"));
// The credential now lives on the virtual authenticatorList<Credential> creds = authenticator.getCredentials();assertEquals(1, creds.size());assertTrue(creds.get(0).isResidentCredential());assertEquals("app.example.com", creds.get(0).getRpId());driver.get("https://app.example.com/account/security")driver.find_element(By.ID, "add-passkey").click()
WebDriverWait(driver, 10).until( EC.text_to_be_present_in_element((By.ID, "passkey-status"), "Passkey added"))
creds = driver.get_credentials()assert len(creds) == 1assert creds[0].is_resident_credentialassert creds[0].rp_id == "app.example.com"await driver.get('https://app.example.com/account/security');await driver.findElement(By.id('add-passkey')).click();
await driver.wait(until.elementTextContains(driver.findElement(By.id('passkey-status')), 'Passkey added'), 10000);
const creds = await driver.getCredentials();assert.strictEqual(creds.length, 1);assert.strictEqual(creds[0].isResidentCredential(), true);assert.strictEqual(creds[0].rpId(), 'app.example.com');driver.Navigate().GoToUrl("https://app.example.com/account/security");driver.FindElement(By.Id("add-passkey")).Click();
new WebDriverWait(driver, TimeSpan.FromSeconds(10)) .Until(d => d.FindElement(By.Id("passkey-status")).Text.Contains("Passkey added"));
var creds = ((WebDriver)driver).GetCredentials();Assert.That(creds.Count, Is.EqualTo(1));Assert.That(creds[0].IsResidentCredential, Is.True);Assert.That(creds[0].RpId, Is.EqualTo("app.example.com"));Login Flow With a Pre-Seeded Credential
To test login without first registering in the same test, add a credential directly. This requires a private key the RP already knows about, so it is used with a test user provisioned in the backend (or after exporting the credential from a registration test).
import java.security.KeyPairGenerator;import java.security.spec.ECGenParameterSpec;import java.util.Base64;
// A P-256 key pair; the public key must be registered with the RP for the test userKeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");kpg.initialize(new ECGenParameterSpec("secp256r1"));KeyPair pair = kpg.generateKeyPair();byte[] privateKey = pair.getPrivate().getEncoded(); // PKCS#8
byte[] credentialId = "test-user-cred-1".getBytes();byte[] userHandle = "user-42".getBytes();
Credential credential = Credential.createResidentCredential( credentialId, "app.example.com", privateKey, userHandle, /* signCount */ 0);authenticator.addCredential(credential);
driver.get("https://app.example.com/login");driver.findElement(By.id("sign-in-with-passkey")).click();new WebDriverWait(driver, Duration.ofSeconds(10)).until(ExpectedConditions.urlContains("/dashboard"));from cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives import serialization
key = ec.generate_private_key(ec.SECP256R1())private_key = key.private_bytes( serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
credential = Credential.create_resident_credential( credential_id=b"test-user-cred-1", rp_id="app.example.com", user_handle=b"user-42", private_key=private_key, sign_count=0,)driver.add_credential(credential)
driver.get("https://app.example.com/login")driver.find_element(By.ID, "sign-in-with-passkey").click()WebDriverWait(driver, 10).until(EC.url_contains("/dashboard"))const { generateKeyPairSync } = require('crypto');
const { privateKey } = generateKeyPairSync('ec', {namedCurve: 'P-256',privateKeyEncoding: { type: 'pkcs8', format: 'der' },publicKeyEncoding: { type: 'spki', format: 'der' },});
const credential = new Credential().createResidentCredential(new Uint8Array(Buffer.from('test-user-cred-1')),'app.example.com',new Uint8Array(Buffer.from('user-42')),Buffer.from(privateKey).toString('base64'),0);await driver.addCredential(credential);
await driver.get('https://app.example.com/login');await driver.findElement(By.id('sign-in-with-passkey')).click();await driver.wait(until.urlContains('/dashboard'), 10000);using System.Security.Cryptography;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);string privateKeyBase64 = Convert.ToBase64String(ecdsa.ExportPkcs8PrivateKey());
var credential = Credential.CreateResidentCredential( Encoding.UTF8.GetBytes("test-user-cred-1"), "app.example.com", privateKeyBase64, Encoding.UTF8.GetBytes("user-42"), signCount: 0);((WebDriver)driver).AddCredential(credential);
driver.Navigate().GoToUrl("https://app.example.com/login");driver.FindElement(By.Id("sign-in-with-passkey")).Click();new WebDriverWait(driver, TimeSpan.FromSeconds(10)).Until(d => d.Url.Contains("/dashboard"));Simulating Failure: User Verification Denied
Toggle isUserVerified to false to test the “fingerprint failed” path and assert your app’s error handling.
authenticator.setUserVerified(false);driver.findElement(By.id("sign-in-with-passkey")).click();new WebDriverWait(driver, Duration.ofSeconds(10)) .until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".auth-error")));driver.set_user_verified(False)driver.find_element(By.ID, "sign-in-with-passkey").click()WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, ".auth-error")))await driver.setUserVerified(false);await driver.findElement(By.id('sign-in-with-passkey')).click();await driver.wait(until.elementLocated(By.css('.auth-error')), 10000);((WebDriver)driver).SetUserVerified(false);driver.FindElement(By.Id("sign-in-with-passkey")).Click();new WebDriverWait(driver, TimeSpan.FromSeconds(10)).Until(d => d.FindElement(By.CssSelector(".auth-error")).Displayed);Cleanup
Remove credentials or the whole authenticator between tests so state does not leak. Quitting the driver also discards it.
authenticator.removeCredential(credentialId); // oneauthenticator.removeAllCredentials(); // all((HasVirtualAuthenticator) driver).removeVirtualAuthenticator(authenticator);driver.remove_credential(b"test-user-cred-1")driver.remove_all_credentials()driver.remove_virtual_authenticator()await driver.removeCredential(new Uint8Array(Buffer.from('test-user-cred-1')));await driver.removeAllCredentials();await driver.removeVirtualAuthenticator();((WebDriver)driver).RemoveCredential(Encoding.UTF8.GetBytes("test-user-cred-1"));((WebDriver)driver).RemoveAllCredentials();((WebDriver)driver).RemoveVirtualAuthenticator(authenticatorId);Practical Notes
- The RP id must match the page’s domain (or a registrable parent). Tests against
localhostwork withrpId = "localhost". - WebAuthn requires a secure context: HTTPS, or
localhost. - Chrome’s
--enable-features=WebAuthenticationRemoteDesktopSupportis not needed; the virtual authenticator is part of WebDriver. - Safari does not support the virtual authenticator API. Cover Safari passkey flows manually or with a mocked RP endpoint.
- The
signCountincrements on every assertion; RPs that enforce monotonic counters will reject a re-seeded credential with a lower count. Reset the backend’s stored count or use a fresh credential id.
Summary
- Selenium 4’s virtual authenticator answers WebAuthn prompts so passkey and security-key flows are fully automatable.
- Use
ctap2+internal+ resident keys for passkeys,u2f+usbfor legacy keys. - Register through the UI to test sign-up; seed credentials to test sign-in; flip
isUserVerifiedto test failures. - Clean up credentials between tests; Safari needs a different approach.