Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Webauthn Emulator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. src/Authenticator.php (or the equivalent entry class) — this is the whole product.
  3. The credential repository class — an in-memory store that holds credentials between makeCredential() and getAssertion(), which is how the emulator "remembers" the key it just registered.
  4. 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.

Implementation Patterns

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."

Gotchas and Tips

  • It is dev-only, full stop. Nothing in this package should be reachable from production code paths. Keep it in require-dev and gate any dev route on app()->environment('local').
  • Origin and RP ID mismatch is the number one cause of "the signature is valid but it still fails." WebAuthn compares origins exactly: 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.
  • base64url, not base64. The 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.
  • Challenge must be single-use and match the session. If your app stores the challenge in the session or cache, a test that supplies its own challenge will "pass" the library and fail your app — or worse, pass for the wrong reason.
  • Attestation is the weak spot. 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.
  • Signature counter semantics vary. Do not write assertions about 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.
  • Flags you don't set are flags you don't test. 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.
  • Debugging technique: dump the raw JSON payload before POSTing it. Nearly every failure is visible in that blob (wrong challenge, wrong origin, padded base64) before it reaches your validator.
  • Maintenance risk is real. Five stars, zero dependents, single maintainer. Pin an exact version in 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.
  • Wrong tool for browser E2E. This cannot simulate a real device in a browser session. For end-to-end tests use Playwright/Chrome DevTools Protocol virtual authenticators; use this package for server-side feature tests only.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource