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 Laravel Package

madwizard/webauthn

Server-side WebAuthn (FIDO2) implementation for PHP. Register and authenticate users with hardware security keys, platform authenticators, and passkeys, including attestation validation and framework-agnostic integration.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require madwizard/webauthn nyholm/psr7

You need a PSR-7/PSR-17 implementation (nyholm/psr7, guzzlehttp/psr7, or Laravel's existing stack if you already pull one in) because the library works with PSR-7 messages internally but doesn't ship a factory. Check composer.json for the minimum PHP version before committing.

Where to look first

  • README.md — the two ceremonies (register / authenticate) and a working example flow.
  • src/WebAuthn.php — the façade you'll inject into controllers.
  • src/Config/ServerConfig.php + RelyingParty.php — the relying-party identity and allowed origins. Get these right or every ceremony fails.
  • CredentialStoreInterface — the one interface you must implement. There is no Eloquent model, migration, or config file shipped with the package.
  • examples/ — an end-to-end sample you can lift into a Laravel service provider.

Minimal setup for a first use case — "add a passkey" on the logged-in user's profile page:

  1. Build the RP config:
    $rp = new RelyingParty('My App', 'example.com');
    $config = new ServerConfig($rp, ['https://example.com']);
    
  2. Bind WebAuthn as a singleton in a service provider, passing your credential store.
  3. POST /webauthn/register/options — build creation options, stash them in the session, return JSON.
  4. POST /webauthn/register — pull options from session, verify the browser response, persist the credential.
  5. Render the returned options with navigator.credentials.create() and POST the result back.

Once that works, add the authentication ceremony (login with passkey) as the second increment.

Implementation Patterns

Service provider + config. Treat the library as infrastructure and wrap it once:

// config/webauthn.php
return [
    'rp_name'    => env('APP_NAME', 'My App'),
    'rp_id'      => env('WEBAUTHN_RP_ID', parse_url(env('APP_URL'), PHP_URL_HOST)),
    'origins'    => [env('APP_URL')],
    'timeout'    => 60000,
    'challenge_length' => 32,
];
// app/Providers/WebAuthnServiceProvider.php
public function register(): void
{
    $this->app->singleton(WebAuthn::class, function ($app) {
        $rp = new RelyingParty(config('webauthn.rp_name'), config('webauthn.rp_id'));
        $config = new ServerConfig($rp, config('webauthn.origins'));

        return new WebAuthn($config, $app->make(CredentialStoreInterface::class));
    });

    $this->app->bind(CredentialStoreInterface::class, EloquentCredentialStore::class);
}

Credential storage. A migration with the fields the library exposes:

Schema::create('webauthn_credentials', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('credential_id', 255)->unique();   // base64url
    $table->binary('public_key');                      // raw COSE key
    $table->unsignedBigInteger('sign_count')->default(0);
    $table->string('attestation_type')->nullable();
    $table->json('transports')->nullable();
    $table->timestamp('last_used_at')->nullable();
    $table->timestamps();
});
class EloquentCredentialStore implements CredentialStoreInterface
{
    public function getCredential(CredentialId $id): ?Credential
    {
        $row = WebAuthnCredential::where('credential_id', $id->toBase64Url())->first();
        if (!$row) return null;

        return new Credential(
            $id,
            new User((string) $row->user_id, $row->user->email, $row->user->name),
            CoseKey::fromBytes($row->public_key),
            $row->sign_count,
        );
    }

    public function save(Credential $credential): void
    {
        WebAuthnCredential::updateOrCreate(
            ['credential_id' => $credential->getCredentialId()->toBase64Url()],
            [
                'user_id'         => $credential->getUser()->getId(),
                'public_key'      => $credential->getPublicKey()->getBytes(),
                'sign_count'      => $credential->getSignCount(),
                'last_used_at'    => now(),
            ],
        );
    }
}

Constructor/accessor names vary slightly across releases — confirm against src/Credential/Credential.php and src/Crypto/CoseKey.php for your installed version.

Registration controller. Two endpoints, options then verify:

public function options(Request $request, WebAuthn $webAuthn)
{
    $user = $request->user();
    $userEntity = new User((string) $user->id, $user->email, $user->name);

    $options = $webAuthn->createRegistrationOptions($userEntity, /* exclude credentials */ []);
    $request->session()->put('webauthn.register.options', serialize($options));

    return response()->json($options);
}

public function verify(Request $request, WebAuthn $webAuthn)
{
    $options = unserialize($request->session()->pull('webauthn.register.options'));
    abort_unless($options, 419);

    $result = $webAuthn->verifyRegistration(
        $request->input('clientDataJSON'),
        $request->input('attestationObject'),
        $options,
        new User((string) $request->user()->id, $request->user()->email, $request->user()->name),
    );

    app(CredentialStoreInterface::class)->save($result->getCredential());

    return response()->json(['ok' => true]);
}

Authentication controller. Same shape, but the options carry allowCredentials (or none, for usernameless/discoverable login). For usernameless login, the response contains a userHandle; look up the credential by ID, then resolve the user from the stored credential record rather than trusting a posted user ID.

$allowed = WebAuthnCredential::where('user_id', $request->user()->id)
    ->pluck('credential_id')
    ->map(fn ($id) => new CredentialId($id))
    ->all();

$options = $webAuthn->createAuthenticationOptions($allowed);
$request->session()->put('webauthn.login.options', serialize($options));

Frontend. Use the modern JSON helpers where supported, with a base64url fallback for older browsers:

// register
const options = await fetch('/webauthn/register/options', { method: 'POST', headers }).then(r => r.json());
const cred = await navigator.credentials.create({
  publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
});
await fetch('/webauthn/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', ...headers },
  body: JSON.stringify(cred.toJSON()),
});
// login
const options = await fetch('/webauthn/login/options', { method: 'POST', headers }).then(r => r.json());
const assertion = await navigator.credentials.get({
  publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
});
await fetch('/webauthn/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', ...headers },
  body: JSON.stringify(assertion.toJSON()),
});

UX pattern. Keep password login, add "Sign in with a passkey" and a profile-page "Add passkey" card listing existing credentials with a delete action. Use excludeCredentials on registration so the same authenticator doesn't register twice.

Testing. Drive a virtual authenticator with Chrome DevTools Protocol via Pest/Dusk/Playwright:

await client.send('WebAuthn.enable');
await client.send('WebAuthn.addVirtualAuthenticator', {
  options: { protocol: 'ctap2', transport: 'internal',
             hasResidentKey: true, hasUserVerification: true, isUserVerified: true },
});

Gotchas and Tips

  • Origin must match byte-for-byte. Scheme, host, and port in ServerConfig must equal the browser's origin in clientDataJSON. Behind a load balancer or proxy, configure TrustProxies and set APP_URL to the public URL, or every ceremony fails with a confusing challenge/origin error.
  • RP ID rules. It must be a registrable suffix of the origin host (example.com covers app.example.com; app.example.com does not cover example.com). localhost is special-cased as a secure context; http://192.168.1.10 is not — test on localhost or HTTPS.
  • Store the challenge server-side, single-use, with TTL. The whole options object in the session is the simplest approach, but make sure your session driver is shared (Redis/DB) across app servers — options are written on request 1 and read on request 2. Pull (don't read) on verify so a challenge can't be replayed. Enforce your own ~5 minute expiry; the library's timeout is only a browser hint.
  • serialize() on the options object is the pragmatic way to persist state, but confirm your version's options classes are serializable. If not, persist the raw challenge + user/credential context and rebuild the options for verification.
  • Sign counter is not monotonic. Synced passkeys (iCloud Keychain, Google Password Manager) and many platform authenticators report 0 or never increment. Do not hard-fail on "counter did not increase"; log it as a soft signal and update the stored value only when it does increase.
  • User handle (user.id) must be opaque, ≤64 bytes, and stable. Use a UUID/ULID, never the email. Changing it silently breaks existing passkeys.
  • Credential ID is binary. Store base64url text (toBase64Url()) rather than a raw binary column — it's indexable, max-length safe, and avoids MySQL BLOB quirks. The spec allows up to ~1023 bytes, though real credentials are ~64.
  • Attestation: start with none. Verified attestation (packed, tpm, fido-u2f, android-key) requires trust anchors and format-specific setup. For a typical SaaS, none is the correct choice; the library still validates the registration signature and challenge.
  • Don't leak verification reasons. The library throws specific exceptions for challenge mismatch, origin mismatch, bad signature, counter regression, etc. Catch the base exception, log the detail, return a generic 422 to the client.
  • Rate-limit both endpoints. The options endpoints are cheap to abuse and the verify endpoints are signature-verification work. Add throttle middleware.
  • CSRF still applies. WebAuthn is a browser API, not a form bypass — keep Laravel's CSRF middleware and send the token in a header from fetch.
  • Multiple credentials per user is the norm. Model hasMany, and offer a delete endpoint that verifies the credential belongs to the authenticated user before removing it.
  • Maintenance risk. This is a 61-star, single-maintainer package with essentially no dependents (score 3.3, opportunity 24.6). It's clean and framework-agnostic, but budget a day for wiring and vetting releases yourself — there's no Laravel integration, no laragear/webauthn-style package, and no migration generator. If long-term support matters more than the API shape, evaluate web-auth/webauthn-lib + laragear/webauthn as a lower-glue alternative before committing.
  • Extension points: if you need custom challenge lifecycle or multi-tenant RP IDs, implement the challenge-storage interface (if present in your version) and construct a WebAuthn instance per tenant with the tenant's RP ID. Don't reuse one singleton across domains.
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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager