jbtronics/2fa-webauthn
Adds WebAuthn-based two-factor authentication to Laravel apps, letting users secure accounts with hardware security keys, passkeys, or built-in biometrics. Includes enrollment, verification, and integration with Laravel's auth scaffolding.
First, the framing: this is a Symfony bundle, not a Laravel package. It provides a TwoFactorProviderInterface implementation for scheb/2fa-bundle and has no service provider, config file, Blade views, or Auth integration for Laravel. If you are a Laravel developer, "getting started" means one of three things: (a) you are working in a Symfony codebase, (b) you are porting the idea and will use web-auth/webauthn-lib directly in Laravel, or (c) you are running a Symfony service alongside your Laravel app. Everything below assumes (a) unless stated otherwise.
Minimal steps:
composer require jbtronics/2fa-webauthn — this pulls scheb/2fa-bundle, web-auth/webauthn-lib, and Doctrine ORM. If you don't already have a working scheb/2fa setup with at least one other provider, stop and do that first; this package is an additional provider, not a 2FA solution on its own. Aim for v3.03 or later — earlier patch releases trigger warnings on current cose-lib versions (see Gotchas).TwoFactorWebauthnBundle in config/bundles.php./2fa/webauthn prefix in config/routes.yaml.UserWebauthnKey-style class holding the credential ID, public key, counter, name, and timestamps) and add your ManyToOne association back to your User. Then generate a Doctrine migration.User entity — the method that returns the user's registered keys — plus make sure your Doctrine user provider can load users by the identifier WebAuthn will send back.scheb_two_factor.two_factor_providers and enable the relevant firewall's 2FA config. The bundle exposes its provider under a service alias; copy the exact snippet from the README rather than guessing.First use case: enroll a YubiKey or platform authenticator (Touch ID / Windows Hello) as a second factor for your admin firewall, alongside TOTP as a fallback. Wire a "Add security key" button on the user's security settings page pointing at the bundle's registration route, and let scheb/2fa render the assertion prompt at login.
The scheb/2fa provider contract. The bundle's provider implements the three-part lifecycle: beginAuthentication() decides whether this provider applies to the user and returns a template/response, checkAuthentication() validates the submitted assertion, and prepareAuthentication() handles the redirect/response. In practice you rarely touch this class — you configure it — but knowing it exists is how you debug "why did login go to the TOTP form instead of the key prompt."
Registration flow. The shipped controller has two halves: a page that returns a PublicKeyCredentialCreationOptions payload, and an endpoint that receives the browser's navigator.credentials.create() result and persists it. The JS is a small wrapper around PublicKeyCredential — no build step required if you include the bundle's asset, but the payload is base64url-encoded and needs decoding before being passed to the browser API. If you use a frontend framework, replicate that decode in your own component instead of pulling in the bundle's vanilla JS.
Multiple keys per user, named. This is the single most valuable pattern: let users register a primary key plus a backup stored somewhere else, and give each a label (MacBook Touch ID, YubiKey 5C in desk drawer). The entity usually carries a name and creation/last-used timestamps — surface those in your account settings so users can audit and revoke.
Compose with recovery codes. Always enable scheb/2fa's backup-code provider in the same firewall. A user who loses their only key otherwise needs manual database surgery.
Step-up authentication. Because the provider is just a Symfony security voter-adjacent flow, you can also invoke it outside the login firewall for sensitive actions (changing email, exporting data) by redirecting through the 2FA flow with a target_path back to the action. This is where WebAuthn shines over TOTP.
Testing. Do not hand-craft assertion JSON. Two practical routes: unit-test your validator wiring with fixture credentials from webauthn-lib's test data, and drive end-to-end tests with Symfony Panther (or Playwright over CDP) using WebAuthn.addVirtualAuthenticator — Chrome DevTools Protocol gives you a fully scriptable, headless authenticator with configurable user-verification behaviour.
If you're porting to Laravel. Take the mental model, not the code: use web-auth/webauthn-lib directly (it is framework-agnostic), store credentials in an Eloquent model mirroring the bundle's columns, expose a POST /webauthn/register/options → POST /webauthn/register pair, and gate it behind password.confirm plus a custom middleware instead of scheb's firewall. Budget a day; the library does the cryptography, you write ~300 lines of glue.
Upgrading to v3.03. The only behaviour change is the removal of the non-standard Ed256/Ed512 COSE algorithm IDs. If you never deliberately enabled those (you almost certainly didn't — they were only reachable if you hand-built PublicKeyCredentialCreationOptions with a custom Algorithm list), the upgrade is a no-op. If you did, do a data audit before deploying: query your key table for any stored algorithm identifier that isn't a standard ES256/RS256/EdDSA value, because those credentials will stop verifying and users must re-register.
localhost. Plain HTTP on 192.168.x.x or a Docker hostname silently fails. Use mkcert for local dev with a real hostname if you need to test multi-device.example.com vs app.example.com before you ship, and note that an RP ID of example.com allows credentials created on any subdomain.http://internal-host and the assertion's clientDataJSON.origin won't match. Configure trusted_proxies and framework.trusted_hosts, and verify X-Forwarded-Proto is honoured — this is the #1 "works on my machine" production failure with this class of package.NotAllowedError is ambiguous. It means cancelled, timed out, or no matching credential. Don't log it as a security event or you'll drown in noise; show "try again" and move on.0, and some reset. Do not hard-fail on a non-increasing counter unless you want support tickets; log it as a signal instead.getUserIdentifier() is baked into the credential. Changing how users are identified (email → UUID, or letting users change email) can orphan existing credentials depending on whether you rely on the user handle or the allow-list. Prefer an immutable ID in the user handle.authenticatorAttachment: cross-platform you exclude Touch ID/Windows Hello; if you require userVerification: required you exclude some older keys. For a second factor, preferred plus a broad attachment policy is usually the right default.Permissions-Policy: publickey-credentials-get header. Relevant if your login form is embedded.cose-lib releases warn about them; the bundle now refuses them outright. Staying on v3.0.x to keep them is the worse trade — you inherit the warning and a future collision, since their IDs overlap with the official EdDSA registry. Upgrade, and re-register the (hopefully zero) affected keys.web-auth/webauthn-lib has had breaking major releases (v3 → v4 → v5 changed namespaces and option builders), and this bundle pins a range. The Ed256/Ed512 removal is downstream of a cose-lib bump, so the two are coupled: run composer why web-auth/webauthn-lib and composer why cose/cose-lib before adding anything else that touches them, and don't hand-pin cose-lib back to an older minor to silence the warning.clientDataJSON and authenticatorData (base64) on failure. Nearly every error reduces to "origin mismatch," "RP ID hash mismatch," or "challenge mismatch," and those three are distinguishable in ten seconds from those two blobs. After the v3.03 upgrade you can add a fourth: an assertion rejected with an unexpected-algorithm error means a pre-existing Ed256/Ed512 credential, not a bug in your config.How can I help you explore Laravel packages today?