DEV Community

Cover image for Passkeys in 17 Files: Hybrid Login, Recovery Codes and Revocation in Symfony 8.1
Matt Mochalkin
Matt Mochalkin

Posted on

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 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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(); }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# config/routes/mattleads_passkeys.yaml
mattleads_passkeys:
    resource: '@MattLeadsPasskeysBundle/config/routes.php'
Enter fullscreen mode Exit fullscreen mode

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'); }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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' %}
Enter fullscreen mode Exit fullscreen mode

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": []
}
Enter fullscreen mode Exit fullscreen mode
// assets/app.js
import './stimulus_bootstrap.js';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8765 and walk through:

  1. Create account, then sign in with the password. The flow endpoint returned password, so the form revealed a password field.
  2. 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, internal for a synced platform passkey), whether it is backed up, and Last used: never.
  3. Sign out. Type the email and press Continue. This time the flow endpoint says passkey, the browser prompts, and you land on /account without ever seeing a password field. Back on the passkeys page, Last used now has a timestamp.
  4. 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.
  5. 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 with RequestBodyUserEntityGuesser and a CanRegisterUserEntity repository.
  • Production: HTTPS, RELYING_PARTY_ID set to your apex domain so passkeys work on all subdomains, allowed_origins listing every origin that will call the endpoints, migrations instead of schema:create, and a real session storage.
  • Styling: override any template under templates/bundles/MattLeadsPasskeysBundle/. The Stimulus controllers dispatch error, authenticated and armed events 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:

Top comments (0)