mattleads / passkeys-bundle
Batteries-included passkeys for Symfony: hybrid passkey/password login flow, conditional-UI autofill, passkey management, last-used tracking and offline recovery codes on top of web-auth/webauthn-symfony-bundle.
Package info
github.com/mattleads/passkeys-bundle
Type:symfony-bundle
pkg:composer/mattleads/passkeys-bundle
Requires
- php: >=8.2
- psr/clock: ^1.0
- symfony/config: ^6.4|^7.0|^8.0
- symfony/dependency-injection: ^6.4|^7.0|^8.0
- symfony/event-dispatcher: ^6.4|^7.0|^8.0
- symfony/framework-bundle: ^6.4|^7.0|^8.0
- symfony/http-foundation: ^6.4|^7.0|^8.0
- symfony/http-kernel: ^6.4|^7.0|^8.0
- symfony/password-hasher: ^6.4|^7.0|^8.0
- symfony/routing: ^6.4|^7.0|^8.0
- symfony/security-bundle: ^6.4|^7.0|^8.0
- symfony/security-http: ^6.4|^7.0|^8.0
- symfony/serializer: ^6.4|^7.0|^8.0
- web-auth/webauthn-symfony-bundle: ^5.2
Requires (Dev)
- doctrine/doctrine-bundle: ^2.13|^3.0
- doctrine/orm: ^3.3
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5
- symfony/asset-mapper: ^6.4|^7.0|^8.0
- symfony/clock: ^6.4|^7.0|^8.0
- symfony/twig-bundle: ^6.4|^7.0|^8.0
- symfony/uid: ^6.4|^7.0|^8.0
- symfony/yaml: ^6.4|^7.0|^8.0
Suggests
- doctrine/orm: To use the bundled Doctrine storage for recovery codes.
- symfony/asset-mapper: To load the bundled Stimulus controllers without Node.
- symfony/stimulus-bundle: To auto-register the bundled Stimulus controllers.
- symfony/twig-bundle: To use the bundled, overridable Twig templates.
Provides
None
Conflicts
None
Replaces
None
README
Batteries-included passkeys for Symfony, built on top of web-auth/webauthn-symfony-bundle.
The WebAuthn bundle gives you the cryptographic ceremonies. This bundle adds the product around them:
| Feature | What you get |
|---|---|
| Hybrid login flow | One form. GET /login/flow?identifier=… tells the page whether to start a passkey ceremony or reveal a password field. |
| Conditional UI (autofill) | Passkeys offered straight from the browser's username autofill. |
| Recovery codes | Single-use offline codes with slow hashing + peppered lookup, a RecoveryCodeAuthenticator for your firewall, Doctrine storage included. |
| Last-used tracking | Implement one interface on your credential entity; every successful assertion stamps lastUsedAt. |
| Management pages | List, revoke, regenerate recovery codes — Twig templates you can override. |
| Stimulus controllers | hybrid-login, passkey, confirm — no alert()/confirm() dialogs, events for your own UI, works with AssetMapper (no Node). |
Requirements
- PHP 8.2+, Symfony 6.4 / 7.x / 8.x
web-auth/webauthn-symfony-bundle^5.2 configured with your own user-entity and credential repositories- Optional: Doctrine ORM (recovery-code storage), Twig (pages), AssetMapper or Encore + StimulusBundle (controllers)
Installation
composer require mattleads/passkeys-bundle
// config/bundles.php MattLeads\PasskeysBundle\MattLeadsPasskeysBundle::class => ['all' => true],
# config/routes/mattleads_passkeys.yaml mattleads_passkeys: resource: '@MattLeadsPasskeysBundle/config/routes.php'
If you use the Doctrine storage for recovery codes (the default when Doctrine ORM is installed), create the table:
bin/console make:migration && bin/console doctrine:migrations:migrate
Configuration
# config/packages/mattleads_passkeys.yaml mattleads_passkeys: login_flow: enabled: true path: /login/flow unknown_user_flow: password # do not reveal whether an account exists recovery: enabled: true storage: null # service id implementing RecoveryCodeStorageInterface; null = bundled Doctrine storage count: 10 length: 10 login_path: /login/recovery identifier_parameter: _identifier code_parameter: _recovery_code default_target_path: / csrf: true usage_tracking: enabled: true management: enabled: true route_prefix: /account/passkeys add_passkey: # WebAuthn creation-controller routes → shows "Add a passkey" (see below) options_route: webauthn.controller.creation.request.add_passkey result_route: webauthn.controller.creation.response.add_passkey
Firewall and WebAuthn endpoints
# config/packages/security.yaml security: firewalls: main: lazy: true provider: app_user_provider entry_point: form_login form_login: # password half of the hybrid form (remove for passwordless-only) login_path: app_login check_path: app_login enable_csrf: true webauthn: # from web-auth/webauthn-symfony-bundle authentication: routes: { options_path: /login/passkey/options, result_path: /login/passkey/result } custom_authenticators: - MattLeads\PasskeysBundle\Security\RecoveryCodeAuthenticator login_throttling: ~ # strongly recommended: recovery codes are short logout: { path: app_logout }
To let a logged-in user add a passkey, use the WebAuthn bundle's standalone creation controllers with the
current-user guesser. (The firewall's registration: block always reads the username from the request body — it is
for passwordless sign-up, not for adding a passkey to an existing account.)
# config/packages/webauthn.yaml webauthn: controllers: enabled: true creation: add_passkey: options_path: /account/passkeys/add/options result_path: /account/passkeys/add/result user_entity_guesser: Webauthn\Bundle\Security\Guesser\CurrentUserEntityGuesser profile: default
# config/packages/mattleads_passkeys.yaml mattleads_passkeys: management: add_passkey: options_route: webauthn.controller.creation.request.add_passkey result_route: webauthn.controller.creation.response.add_passkey
The authenticator uses the normal Passport flow, so login throttling, user checkers, remember-me and CSRF all apply.
The three things your app provides
1. A user-entity repository (WebAuthn bundle requirement)
PublicKeyCredentialUserEntityRepositoryInterface::findOneByUsername() must resolve the same string as UserInterface::getUserIdentifier(). The flow endpoint, the management pages and the usage tracker rely on it.
2. A credential repository that can save and (optionally) delete
use MattLeads\PasskeysBundle\Contract\CanDeleteCredentialRecord; use Webauthn\Bundle\Repository\CanSaveCredentialRecord; use Webauthn\Bundle\Repository\CredentialRecordRepositoryInterface; final class CredentialRepository implements CredentialRecordRepositoryInterface, CanSaveCredentialRecord, CanDeleteCredentialRecord { public function deleteCredentialRecord(CredentialRecord $record): void { /* remove + flush */ } // … }
Implement CanDeleteCredentialRecord to enable the Revoke button.
Pitfall: the WebAuthn validators return a plain Webauthn\CredentialRecord, not your entity. If your entity extends
CredentialRecord (the usual Doctrine setup), convert in saveCredentialRecord() — create your entity on registration,
or update the existing row (counter, backup flags) after a login — otherwise Doctrine fails with "has no identity/no id
values set". The demo application shows a minimal fromRecord() /
syncFrom() pair.
3. (Optional) a credential entity that tracks usage
use MattLeads\PasskeysBundle\Contract\TracksCredentialUsageInterface; #[ORM\Entity] class Credential extends \Webauthn\CredentialRecord implements TracksCredentialUsageInterface { #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $lastUsedAt = null; public function markUsed(\DateTimeImmutable $at): void { $this->lastUsedAt = $at; } public function getLastUsedAt(): ?\DateTimeImmutable { return $this->lastUsedAt; } }
Frontend
AssetMapper (no Node)
bin/console importmap:require @simplewebauthn/browser
With symfony/stimulus-bundle the controllers are auto-registered from the bundle's assets/package.json (UX convention). Otherwise register them manually:
// assets/bootstrap.js import HybridLogin from '@mattleads/passkeys-bundle/dist/hybrid_login_controller.js'; app.register('mattleads--passkeys-bundle--hybrid-login', HybridLogin);
Login template
{{ include('@MattLeadsPasskeys/login/hybrid.html.twig', {
login_check_path: path('app_login'),
options_url: path('webauthn.controller.security.main.request.options'),
result_url: path('webauthn.controller.security.main.request.result'),
flow_url: path('mattleads_passkeys_login_flow'),
recovery_url: path('mattleads_passkeys_recovery_login'),
csrf_token: csrf_token('authenticate'),
last_identifier: last_username,
error: error,
}) }}
Override any template under templates/bundles/MattLeadsPasskeysBundle/ — start with layout.html.twig to plug the pages into your own base layout.
Recovery codes — how they are stored
Each code is stored as a slow hash (bcrypt via Symfony's NativePasswordHasher) plus a 128-bit lookup key = HMAC-SHA256(code, kernel.secret) truncated. Verification is one indexed lookup and one hash check instead of N hash checks, and a leaked database alone cannot be brute-forced offline without the secret. Codes use Crockford base32 (no I, L, O, U), are case-insensitive and ignore dashes/spaces. Generating a new batch invalidates the old one; each code works once.
Use RecoveryCodeManager directly when you need codes outside the bundled pages:
$codes = $recoveryCodes->generate($user->getUserIdentifier()); // show once $recoveryCodes->remaining($user->getUserIdentifier()); $recoveryCodes->verifyAndConsume($identifier, $input); // bool
Bring your own storage by implementing RecoveryCodeStorageInterface and setting recovery.storage.
Security notes
- The flow endpoint returns
passwordfor unknown identifiers by default, so it cannot be used to enumerate accounts. It does reveal that an existing account has a passkey; if that matters, setunknown_user_flow: passkeyand handle the failed ceremony gracefully. - Enable
login_throttlingon the firewall. A 10-character Crockford code has ~50 bits of entropy; throttling makes online guessing irrelevant. - Revocation and recovery-code regeneration are CSRF-protected POSTs (
passkeys_revoke,passkeys_recovery_codes). - WebAuthn requires a secure context: HTTPS everywhere except
localhost.
Demo application
A runnable end-to-end example (password sign-up, hybrid login, add/revoke passkeys, recovery codes, throttling) lives in mattleads/passkeys-demo.
Testing
composer install
composer check # php-cs-fixer, phpstan (level 8), phpunit (unit + kernel with sqlite)
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md. Security issues: see SECURITY.md.
License
MIT — see LICENSE.