Most passkey tutorials end when the browser prompt appears. Production starts there. Your users already have passwords, so they need to add a passkey from their account page, sign in with it next time or pick it from the browser's autofill, see when each key was last used, revoke one, and get back in with a recovery code when every device is gone.
This guide builds exactly that on Symfony 8.1. Two bundles do the heavy lifting: web-auth/webauthn-symfony-bundle runs the FIDO2 ceremonies, and mattleads/passkeys-bundle adds the product layer around them: the login flow, recovery codes, usage tracking, management pages and Stimulus controllers. I maintain the second one. You should be able to follow this guide without reading either bundle's source.
The finished code is at github.com/mattleads/passkeys-demo. You write or edit 17 files. Everything else comes from Flex recipes.
Prerequisites
- PHP 8.2 or later. This guide uses 8.4.
- Composer.
- SQLite, which ships with PHP.
- A browser with a platform authenticator: Touch ID, Windows Hello, Android or a security key.
- No Node.js. AssetMapper serves everything.
Versions used below: symfony/skeleton 8.1.*, web-auth/webauthn-symfony-bundle 5.3.10, mattleads/passkeys-bundle 0.1.1, doctrine/orm 3.7 and symfony/stimulus-bundle 3.6.
1. Skeleton and packages
composer create-project symfony/skeleton:"8.1.*" passkeys-demo cd passkeys-demo composer config extra.symfony.allow-contrib true # lets Flex apply the WebAuthn bundle's recipe composer require mattleads/passkeys-bundle web-auth/webauthn-symfony-bundle \ symfony/security-bundle symfony/twig-bundle symfony/asset-mapper symfony/stimulus-bundle \ doctrine/orm doctrine/doctrine-bundle symfony/uid symfony/serializer symfony/property-access symfony/property-info \ symfony/rate-limiter symfony/lock symfony/password-hasher php bin/console importmap:require @hotwired/stimulus @simplewebauthn/browser symfony/rate-limiter and symfony/lock are for login throttling, which you want because recovery codes are short. @simplewebauthn/browser is the small client library the Stimulus controllers use to talk to navigator.credentials.
Flex registers the bundles and drops config/packages/webauthn.yaml and config/routes/webauthn_routes.yaml for you.
2. Environment
# .env.local APP_SECRET=change-me-to-something-long-and-random DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" RELYING_PARTY_ID=localhost RELYING_PARTY_NAME="Passkeys Demo" WEBAUTHN_ALLOWED_ORIGINS="http://localhost:8765,http://127.0.0.1:8765" Two values matter here. The relying-party id is localhost. WebAuthn only works in a secure context. Browsers make an exception for localhost over plain HTTP, but not for 127.0.0.1, so always open the app as http://localhost:…. The port is part of the origin. If you serve on a different port, change it both here and in webauthn.yaml below, or the ceremony fails with an origin mismatch.
3. Entities
Two entities. The user carries an opaque user handle for WebAuthn, never the email, so the email can change later.
<?php // src/Entity/User.php namespace App\Entity; use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\{PasswordAuthenticatedUserInterface, UserInterface}; use Symfony\Component\Uid\Ulid; use Webauthn\PublicKeyCredentialUserEntity; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: 'app_user')] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] private string $email; #[ORM\Column(length: 26, unique: true)] private string $userHandle; #[ORM\Column(length: 255, nullable: true)] private ?string $password = null; /** @var list<string> */ #[ORM\Column] private array $roles = []; public function __construct(string $email) { $this->email = $email; $this->userHandle = (string) new Ulid(); } public function getUserIdentifier(): string { return $this->email; } public function getUserHandle(): string { return $this->userHandle; } public function getPassword(): ?string { return $this->password; } public function setPassword(?string $password): void { $this->password = $password; } public function getRoles(): array { return array_values(array_unique([...$this->roles, 'ROLE_USER'])); } public function eraseCredentials(): void {} public function toWebAuthnUserEntity(): PublicKeyCredentialUserEntity { return new PublicKeyCredentialUserEntity($this->email, $this->userHandle, $this->email); } } The credential extends the WebAuthn bundle's CredentialRecord, whose fields come from a mapped superclass the bundle registers with Doctrine. We add an id and the usage timestamps, and implement the passkeys bundle's one-method interface so "last used" gets filled in automatically.
<?php // src/Entity/Credential.php namespace App\Entity; use App\Repository\CredentialRepository; use Doctrine\ORM\Mapping as ORM; use MattLeads\PasskeysBundle\Contract\TracksCredentialUsageInterface; use Webauthn\CredentialRecord; #[ORM\Entity(repositoryClass: CredentialRepository::class)] #[ORM\Table(name: 'webauthn_credentials')] #[ORM\HasLifecycleCallbacks] class Credential extends CredentialRecord implements TracksCredentialUsageInterface { #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id = null; #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $createdAt = null; #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $lastUsedAt = null; #[ORM\PrePersist] public function onPrePersist(): void { $this->createdAt ??= new \DateTimeImmutable(); } /** The validators hand back a plain CredentialRecord; we turn it into this entity before saving. */ public static function fromRecord(CredentialRecord $r): self { return new self($r->publicKeyCredentialId, $r->type, $r->transports, $r->attestationType, $r->trustPath, $r->aaguid, $r->credentialPublicKey, $r->userHandle, $r->counter, $r->otherUI, $r->backupEligible, $r->backupStatus, $r->uvInitialized); } /** Fields that change after each successful login. */ public function syncFrom(CredentialRecord $r): void { $this->counter = $r->counter; $this->backupStatus = $r->backupStatus; $this->uvInitialized = $r->uvInitialized; $this->otherUI = $r->otherUI; } public function getLastUsedAt(): ?\DateTimeImmutable { return $this->lastUsedAt; } public function markUsed(\DateTimeImmutable $at): void { $this->lastUsedAt = $at; } } Keep fromRecord() and syncFrom() in mind. Section 4 explains why they exist, and they prevent the first error you would otherwise hit.
4. Repositories
The WebAuthn bundle looks users up by username, and that string must be exactly what getUserIdentifier() returns. If the two disagree, the "add a passkey" page cannot find the current user and the login flow endpoint cannot find anyone.
<?php // src/Repository/UserRepository.php namespace App\Repository; use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Security\Core\User\{PasswordAuthenticatedUserInterface, PasswordUpgraderInterface}; use Webauthn\Bundle\Repository\PublicKeyCredentialUserEntityRepositoryInterface; use Webauthn\PublicKeyCredentialUserEntity; final class UserRepository extends ServiceEntityRepository implements PublicKeyCredentialUserEntityRepositoryInterface, PasswordUpgraderInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, User::class); } public function findOneByUsername(string $username): ?PublicKeyCredentialUserEntity { return $this->findOneBy(['email' => $username])?->toWebAuthnUserEntity(); } public function findOneByUserHandle(string $userHandle): ?PublicKeyCredentialUserEntity { return $this->findOneBy(['userHandle' => $userHandle])?->toWebAuthnUserEntity(); } public function save(User $user): void { $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); } public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { if ($user instanceof User) { $user->setPassword($newHashedPassword); $this->getEntityManager()->flush(); } } } For credentials, the WebAuthn bundle ships a Doctrine repository that already handles the base64 storage of binary ids. Extend it, add deletion so the passkeys bundle can offer Revoke, and override saveCredentialRecord():
<?php // src/Repository/CredentialRepository.php namespace App\Repository; use App\Entity\Credential; use Doctrine\Persistence\ManagerRegistry; use MattLeads\PasskeysBundle\Contract\CanDeleteCredentialRecord; use Webauthn\Bundle\Repository\DoctrineCredentialSourceRepository; use Webauthn\CredentialRecord; final class CredentialRepository extends DoctrineCredentialSourceRepository implements CanDeleteCredentialRecord { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Credential::class); } public function saveCredentialRecord(CredentialRecord $credentialRecord): void { if (!$credentialRecord instanceof Credential) { $existing = $this->findOneByCredentialId($credentialRecord->publicKeyCredentialId); if ($existing instanceof Credential) { $existing->syncFrom($credentialRecord); // after a login: counter, backup flags $credentialRecord = $existing; } else { $credentialRecord = Credential::fromRecord($credentialRecord); // registration } } parent::saveCredentialRecord($credentialRecord); } public function deleteCredentialRecord(CredentialRecord $credentialRecord): void { $this->getEntityManager()->remove($credentialRecord); $this->getEntityManager()->flush(); } } The conversion exists because the attestation and assertion validators construct a plain Webauthn\CredentialRecord. Persist that directly and Doctrine throws "The given entity of type 'Webauthn\CredentialRecord' has no identity/no id values set", because only your subclass has an id mapping. Registration needs a new entity. A login needs the existing row updated, because the signature counter and the backup state changed.
5. Security configuration
# config/packages/security.yaml security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' providers: app_users: entity: { class: App\Entity\User, property: email } firewalls: dev: pattern: ^/(_profiler(/|$)|(_wdt|assets|build)/) security: false main: lazy: true provider: app_users entry_point: form_login form_login: login_path: app_login check_path: app_login username_parameter: _username password_parameter: _password enable_csrf: true default_target_path: app_account webauthn: authentication: routes: options_path: /login/passkey/options result_path: /login/passkey/result custom_authenticators: - MattLeads\PasskeysBundle\Security\RecoveryCodeAuthenticator login_throttling: max_attempts: 5 logout: path: app_logout target: app_login access_control: - { path: ^/account, roles: ROLE_USER } Three authenticators live on this firewall: password form, WebAuthn assertion and recovery code. Symfony needs entry_point to know where to send anonymous visitors. Forget it and the container refuses to compile with a long message about "multiple authenticators".
There is no registration: block under webauthn:, and that is deliberate. That block is for passwordless sign-up and always reads the username from the request body. To add a passkey to the account you are already logged into, use the WebAuthn bundle's standalone creation controllers instead:
# config/packages/webauthn.yaml webauthn: credential_repository: App\Repository\CredentialRepository user_repository: App\Repository\UserRepository allowed_origins: ['http://localhost:8765', 'http://127.0.0.1:8765'] creation_profiles: default: rp: name: '%env(RELYING_PARTY_NAME)%' id: '%env(RELYING_PARTY_ID)%' authenticator_selection_criteria: resident_key: required # discoverable credential → shows up in browser autofill user_verification: preferred request_profiles: default: rp_id: '%env(RELYING_PARTY_ID)%' user_verification: preferred 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 resident_key: required makes the passkey discoverable, which lets the browser offer it from the username field's autofill (conditional UI). Without it you get a credential that only works when the server names it first.
The creation controllers above are registered as routes named webauthn.controller.creation.request.add_passkey and …response.add_passkey. Tell the passkeys bundle about them so its management page can render the "Add a passkey" button, and import its routes:
# config/packages/mattleads_passkeys.yaml mattleads_passkeys: recovery: default_target_path: /account management: add_passkey: options_route: webauthn.controller.creation.request.add_passkey result_route: webauthn.controller.creation.response.add_passkey # config/routes/mattleads_passkeys.yaml mattleads_passkeys: resource: '@MattLeadsPasskeysBundle/config/routes.php' That gives you /login/flow, /login/recovery, /account/passkeys, /account/passkeys/revoke and /account/passkeys/recovery-codes.
6. Controllers
Two of ours. A security controller for the login page, logout and the account page:
<?php // src/Controller/SecurityController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; final class SecurityController extends AbstractController { #[Route('/login', name: 'app_login')] public function login(AuthenticationUtils $utils): Response { if ($this->getUser()) { return $this->redirectToRoute('app_account'); } return $this->render('security/login.html.twig', [ 'error' => $utils->getLastAuthenticationError(), 'last_username' => $utils->getLastUsername(), ]); } #[Route('/logout', name: 'app_logout')] public function logout(): never { throw new \LogicException('Handled by the firewall.'); } #[Route('/', name: 'app_home')] public function home(): Response { return $this->redirectToRoute($this->getUser() ? 'app_account' : 'app_login'); } #[Route('/account', name: 'app_account')] #[IsGranted('ROLE_USER')] public function account(): Response { return $this->render('account/index.html.twig'); } } And a deliberately plain registration controller: email and password, no Form component, so the tutorial stays about passkeys. The repository has the full version with validation and a CSRF check.
$user = new User($email); $user->setPassword($hasher->hashPassword($user, $password)); $users->save($user); Users then add passkeys from their account page. This progressive adoption path is the one most real applications want. Passwordless sign-up is a separate topic.
7. Templates
A base layout with the importmap, and a login page that includes the bundle's hybrid form. The include is the whole integration on the frontend:
{# templates/security/login.html.twig #} {% extends 'base.html.twig' %} {% block body %} <h1>Sign in</h1> {{ 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, }) }} <p><a href="{{ path('app_register') }}">No account yet? Create one</a></p> {% endblock %} The two webauthn.controller.security.main.request.* routes are created by the firewall's authentication: block. main is the firewall name.
The bundle's own pages (passkey list, recovery codes, recovery login) extend @MattLeadsPasskeys/layout.html.twig. Override that one file to pull them into your layout:
{# templates/bundles/MattLeadsPasskeysBundle/layout.html.twig #} {% extends 'base.html.twig' %} base.html.twig needs {{ importmap('app') }} in the head and, for the demo, a nav with links to app_account, mattleads_passkeys_manage and app_logout.
8. Frontend without Node
Enable the bundle's Stimulus controllers:
// assets/controllers.json { "controllers": { "@mattleads/passkeys-bundle": { "hybrid-login": { "enabled": true, "fetch": "eager" }, "passkey": { "enabled": true, "fetch": "lazy" }, "confirm": { "enabled": true, "fetch": "lazy" } } }, "entrypoints": [] } // assets/app.js import './stimulus_bootstrap.js'; StimulusBundle reads the bundle's assets/package.json, AssetMapper serves the files from vendor/, and the browser imports @simplewebauthn/browser from the importmap you populated in step 1. Check with php bin/console debug:asset-map | grep passkeys. You should see the three controllers and webauthn_service.js.
9. Run it
php bin/console doctrine:schema:create php -S localhost:8765 -t public public/index.php Open http://localhost:8765 and walk through:
- Create account, then sign in with the password. The flow endpoint returned
password, so the form revealed a password field. - Passkeys, then Add a passkey to this device, then approve with Touch ID or Windows Hello. The table shows the new credential, its transports (
hybrid, internalfor a synced platform passkey), whether it is backed up, and Last used: never. ...

