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.
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:
$rp = new RelyingParty('My App', 'example.com');
$config = new ServerConfig($rp, ['https://example.com']);
WebAuthn as a singleton in a service provider, passing your credential store.POST /webauthn/register/options — build creation options, stash them in the session, return JSON.POST /webauthn/register — pull options from session, verify the browser response, persist the credential.navigator.credentials.create() and POST the result back.Once that works, add the authentication ceremony (login with passkey) as the second increment.
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.phpandsrc/Crypto/CoseKey.phpfor 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 },
});
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.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.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.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.id) must be opaque, ≤64 bytes, and stable. Use a UUID/ULID, never the email. Changing it silently breaks existing passkeys.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.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.422 to the client.throttle middleware.fetch.hasMany, and offer a delete endpoint that verifies the credential belongs to the authenticated user before removing it.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.WebAuthn instance per tenant with the tenant's RP ID. Don't reuse one singleton across domains.How can I help you explore Laravel packages today?