pronin/webauthn-emulator
A PHP library that emulates WebAuthn authenticators for testing: generate attestation and assertion responses, simulate security keys, and exercise registration and login flows in Laravel and plain PHP test suites without hardware.
First, set expectations: this package is not a Laravel auth driver and it does not touch the browser. It is a server-side emulator of a WebAuthn authenticator — it produces the attestation/assertion payloads that a real YubiKey or Touch ID prompt would produce, so you can exercise your registration and login endpoints in PHPUnit without a device. That makes it a require-dev dependency, not a runtime one.
composer require --dev pronin/webauthn-emulator
There is no service provider, no config file, no facade, and nothing to publish. You instantiate plain PHP objects in a test and call them directly. If you are looking for something to php artisan vendor:publish, you are in the wrong package.
Look in this order:
README.md usage example — the API surface is small (roughly: construct an authenticator with a credential repository, call makeCredential() for registration, getAssertion() for login). Method and option-object names are the thing most likely to have drifted from the README, so confirm against source.src/Authenticator.php (or the equivalent entry class) — this is the whole product.makeCredential() and getAssertion(), which is how the emulator "remembers" the key it just registered.tests/ — the package's own tests are the most reliable documentation for option shapes.Your first use case should be a single feature test for registration:
public function test_user_can_register_a_security_key(): void
{
$authenticator = new Authenticator(new InMemoryCredentialRepository());
// Get the challenge from YOUR app, not from thin air.
$options = $this->postJson('/webauthn/register/options')->json();
$attestation = $authenticator->makeCredential(
rpId: 'localhost',
challenge: $options['challenge'],
userHandle: $user->id,
userName: $user->email,
);
$this->actingAs($user)
->postJson('/webauthn/register', $attestation)
->assertOk();
$this->assertDatabaseHas('webauthn_credentials', ['user_id' => $user->id]);
}
If that passes, you have validated the half of your WebAuthn stack that is normally only testable by hand.
Two-phase flow, driven from your own endpoints. The single most important pattern: never generate the challenge in the test. Call your /options endpoint, read challenge, rpId, and user.id out of the response, and feed those into the emulator. Otherwise you are testing the emulator against itself and your app's session plumbing is untested.
Wrap it in a test helper, not scattered across 30 test files. Because there is no Laravel integration, build your own. A trait or a small factory in tests/Support that returns a bootstrapped authenticator is the extension point you actually have:
trait EmulatesWebauthn
{
protected function emulator(): Authenticator
{
return new Authenticator(new InMemoryCredentialRepository());
}
protected function registerKey(User $user, Authenticator $a = null): array
{
$a ??= $this->emulator();
$options = $this->actingAs($user)->postJson('/webauthn/register/options')->json();
$attestation = $a->makeCredential(/* from $options */);
$this->actingAs($user)->postJson('/webauthn/register', $attestation)->assertOk();
return $attestation;
}
}
Then tests read as $this->registerKey($user) followed by a login assertion.
Reuse the same authenticator instance across registration and login. The in-memory credential repository is the emulator's memory of the key. A fresh instance will not know the credential and getAssertion() will fail — which is correct behaviour, but a confusing failure the first time you hit it.
Seed multiple credentials per user. Register two authenticators against the same user to test your "which key is this?" lookup path, the allowCredentials filtering, and the "no matching credential" rejection.
Use data sets to cover algorithm and flag variants. If the emulator exposes algorithm selection and UV/resident-key flags, a Pest data set over ES256 / RS256 / EdDSA and userVerification: required|preferred|discouraged is cheap coverage for code paths that otherwise only break in production on someone's Windows Hello.
Manual testing behind an environment guard. A local-only route or Artisan command that emits a valid assertion payload is genuinely useful for hitting your real endpoints with curl/Postman when you cannot easily script a browser:
if (app()->environment('local')) {
Route::get('/_dev/webauthn/assertion', DevWebauthnController::class);
}
Guard it with environment('local'), not config('app.debug'), and never register it in the production route file.
Pair it with your validator's exception types. If you validate with web-auth/webauthn-lib, assert on the specific exceptions (InvalidCredentialException, etc.) rather than a generic 422. That separates "my emulator payload is malformed" from "my app rejected a valid credential."
require-dev and gate any dev route on app()->environment('local').http://localhost ≠ http://127.0.0.1, and the port is part of the origin. Make the rpId you hand the emulator match config('app.url') and whatever your validator expects.challenge and credential IDs are base64url (no padding, -_ not +/). Padding or alphabet mistakes surface as crypto/parse errors that look like a library bug. If you decode/encode manually in the test helper, do it once, correctly, and reuse.none attestation is the easy path. If your app validates attestation against a trust chain or metadata service, the emulator output will not satisfy it; stub the trust decision rather than trying to make the emulator produce a real chain.signCount incrementing or clone detection unless you have confirmed how this emulator handles it. Test clone detection by directly manipulating the stored counter in the DB instead.userVerification=required and residentKey=required are opt-in on the emulator. If your app enforces them, tell the emulator — otherwise your suite only ever proves the permissive path works.composer.json, expect the README to lag the source, and keep all your usage behind your own test helper so swapping in a hand-rolled fake or a different library later is a one-file change.How can I help you explore Laravel packages today?