Passkeys in 17 Files: Hybrid Login, Recovery Codes and Revocation in Symfony 8.1
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
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. - Sign out. Type the email and press Continue. This time the flow endpoint says
passkey, the browser prompts, and you land on/accountwithout ever seeing a password field. Back on the passkeys page, Last used now has a timestamp. - Generate new recovery codes. Ten codes, shown once. Sign out, open Lost access? and enter one. Each code works exactly once, and after five bad attempts the firewall's throttling kicks in.
- Revoke, which takes two clicks because the button changes to Confirm revoke. Sign out and in again, and you are back to the password field.
One thing to know about step 5: revoking deletes the credential on the server, but the passkey still exists in iCloud Keychain or Google Password Manager as an orphan. That is how passkeys work everywhere. The platform vendors are adding signal APIs so a site can ask the client to clean up, but you cannot rely on them yet.
10. The three errors you will hit if you skip a step
| Error | Cause | Fix |
|---|---|---|
| "has no identity/no id values set" on registration | persisting the validator's plain CredentialRecord
|
convert to your entity in saveCredentialRecord() (section 4) |
| "Unable to find the user entity" when adding a passkey | using the firewall's registration: block, which reads the username from the body |
webauthn.controllers.creation with CurrentUserEntityGuesser (section 5) |
| Ceremony fails with an origin / RP id error | opened via 127.0.0.1, or a port not in allowed_origins
|
use http://localhost:<port> and list that exact origin |
A fourth problem raises no error at all. Without resident_key: required, passkeys never appear in the browser's autofill, and users keep typing passwords.
Where to go from here
-
Passwordless sign-up: the firewall's
registration:block withRequestBodyUserEntityGuesserand aCanRegisterUserEntityrepository. -
Production: HTTPS,
RELYING_PARTY_IDset to your apex domain so passkeys work on all subdomains,allowed_originslisting every origin that will call the endpoints, migrations instead ofschema:create, and a real session storage. -
Styling: override any template under
templates/bundles/MattLeadsPasskeysBundle/. The Stimulus controllers dispatcherror,authenticatedandarmedevents if you want your own UI instead of the built-in error line.
Which path to take depends on your users. If they already have passwords, ship this hybrid flow and let passkeys spread one account page at a time. If you are starting from zero and want no passwords at all, start from the firewall's registration: block instead. Either way, ship recovery codes and login throttling together, because a short code without a rate limit is a brute-force target.
Issues and pull requests on the demo or the bundle are welcome.
Source Code: You can find the full implementation and follow the project's progress on GitHub: https://github.com/mattleads/passkeys-demo
Let's Connect!
If you found this helpful or have questions about the implementation, I'd love to hear from you. Let's stay in touch and keep the conversation going across these platforms:
- LinkedIn: https://www.linkedin.com/in/matthew-mochalkin/
- X (Twitter): https://x.com/MattLeads
- Telegram: https://t.me/MattLeads
- GitHub: https://github.com/mattleads
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.