Skip to main content
SeleniumDecoded

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.

Selenium 4 Medium Updated 9 Sept 2026 · Verified against Selenium 4.48.0

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 (ctap2 for passkeys, u2f for legacy security keys) and a transport (internal for 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

Add a passkey-capable authenticator
Selenium 4 Medium
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.CTAP2
options.transport = Transport.INTERNAL
options.has_resident_key = True
options.has_user_verification = True
options.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.

Register a passkey through the UI and inspect the credential
Selenium 4 Medium
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 authenticator
List<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) == 1
assert creds[0].is_resident_credential
assert 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).

Seed a resident credential and log in
Selenium 4 Medium
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 user
KeyPairGenerator 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 ec
from 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.

Fail user verification
Selenium 4 Medium
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.

Remove credentials and the authenticator
Selenium 4 Stable
authenticator.removeCredential(credentialId); // one
authenticator.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 localhost work with rpId = "localhost".
  • WebAuthn requires a secure context: HTTPS, or localhost.
  • Chrome’s --enable-features=WebAuthenticationRemoteDesktopSupport is 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 signCount increments 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 + usb for legacy keys.
  • Register through the UI to test sign-up; seed credentials to test sign-in; flip isUserVerified to test failures.
  • Clean up credentials between tests; Safari needs a different approach.

Related lessons