Search by

mulertech / passkey-bundle

mulertech

Symfony bundle for passkey (WebAuthn) sign-in: credential storage, key management page and the ceremony asset

Package info

github.com/mulertech/passkey-bundle

Type:symfony-bundle

pkg:composer/mulertech/passkey-bundle

Statistics

Installs: 23

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

v2.0.1 2026-09-10 21:35 UTC

This package is auto-updated.

Last update: 2026-09-10 21:45:19 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub PHPStan Action Status Total Downloads Test Coverage

Passkey (WebAuthn) sign-in for a Symfony application: credential storage, a key management page, and the ceremony asset.

What this bundle is, and is not

web-auth/webauthn-symfony-bundle already serves the four ceremony endpoints, and their paths are declared in your own configuration. This bundle does not reimplement them. It carries what every project otherwise copies: the credential entity and its two repositories, the user handle, the management page, and the JavaScript that drives the ceremonies.

Requirements

  • PHP 8.4+
  • Symfony 6.4+, 7.x or 8.x
  • Doctrine ORM 3, DoctrineBundle 2.12+ or 3.x
  • web-auth/webauthn-symfony-bundle 5.3+

Installation

composer require mulertech/passkey-bundle

1. Register the bundle

// config/bundles.php
return [
    // …
    Webauthn\Bundle\WebauthnBundle::class => ['all' => true],
    MulerTech\PasskeyBundle\MulerTechPasskeyBundle::class => ['all' => true],
];

2. Prepare the user entity

use MulerTech\PasskeyBundle\Security\PasskeyUserInterface;
use MulerTech\PasskeyBundle\Security\WebauthnUserHandleTrait;

class User implements PasskeyUserInterface
{
    use WebauthnUserHandleTrait;

    public function getPasskeyDisplayName(): string
    {
        return $this->fullName ?? $this->email;
    }
}

The trait brings the webauthnUserHandle column, unique and nullable: it is generated the first time the user registers a key.

3. Configure

# config/packages/mulertech_passkey.yaml
parameters:
    app.passkey_register_options_path: /passkey/register/options
    app.passkey_register_path: /passkey/register

mulertech_passkey:
    user_class: App\Entity\User
    register_options_url: '%app.passkey_register_options_path%'
    register_url: '%app.passkey_register_path%'

The two ceremony paths are required, and the same two parameters feed webauthn.controllers below. They are declared once so the page and the endpoints cannot drift apart: the browser only learns the registration failed after it has already accepted the fingerprint, and a 404 at that point looks exactly like a refused key.

user_provider defaults to security.user.provider.concrete.app_user_provider, and template to the page shipped here. Declare either only if yours differs.

4. Import the routes, under the prefix you want

# config/routes/mulertech_passkey.yaml
mulertech_passkey:
    resource: "@MulerTechPasskeyBundle/config/routes.yaml"
    prefix: /passkeys

The management page then answers on /passkeys/, and Symfony redirects the bare prefix to it.

5. Declare the ceremonies

These belong to web-auth, and the paths are yours to choose. The bundle points its repositories at web-auth on its own; what remains is the profile and the endpoints.

# config/packages/webauthn.yaml
webauthn:
    creation_profiles:
        default:
            rp:
                id: '%env(WEBAUTHN_RP_ID)%'
            authenticator_selection_criteria:
                authenticator_attachment: platform
                user_verification: required
                resident_key: required
                require_resident_key: true
    request_profiles:
        default:
            user_verification: required
    controllers:
        enabled: true
        creation:
            default:
                profile: default
                user_entity_guesser: Webauthn\Bundle\Security\Guesser\CurrentUserEntityGuesser
                options_path: '%app.passkey_register_options_path%'
                result_path: '%app.passkey_register_path%'

The ceremony endpoints are served by a dedicated route loader, not by a routes file. Without this import they simply do not exist, and the ceremony answers 404:

# config/routes/webauthn.yaml
webauthn_controllers:
    resource: .
    type: webauthn
# config/packages/security.yaml
security:
    firewalls:
        main:
            webauthn:
                authentication:
                    enabled: true
                    routes:
                        options_path: /passkey/login/options
                        result_path: /passkey/login
                registration:
                    enabled: false
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 2592000

The sign-in ceremony must be reachable without a session — it addresses someone who is not signed in yet. Registering a key, on the other hand, belongs to an open session:

security:
    access_control:
        - { path: ^/login$, roles: PUBLIC_ACCESS }
        - { path: ^/passkey/login, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: ROLE_USER }

Behind ROLE_USER, the ceremony redirects to the login page instead of starting, and the browser reports whatever that redirect leads to rather than the real cause.

remember_me is what the "stay signed in" checkbox below relies on.

Local development over plain HTTP

WebAuthn requires an HTTPS origin, and the library refuses the ceremony without one — localhost included. A development server answering in plain HTTP therefore fails the ceremony with Invalid scheme. HTTPS required., after the fingerprint prompt has already succeeded. Declare the local origin, and only in development:

# config/packages/webauthn.yaml
when@dev:
    webauthn:
        allowed_origins:
            - '%env(WEBAUTHN_ALLOWED_ORIGIN)%'   # http://localhost:8000

In production the origin is HTTPS and the list stays empty, which restores the default requirement.

6. Create the table

The bundle maps its entity; the migration belongs to your project:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

7. Serve the asset

php bin/console importmap:require @simplewebauthn/browser

The pages shipped here carry Tailwind utility classes, which are inert markup without Tailwind. A project using Tailwind has to declare them, since vendor/ is not scanned:

/* assets/styles/app.css */
@source "../../vendor/mulertech/passkey-bundle/templates";
{# in your layout, or on the two pages that need it #}
{{ importmap([], { nonce: csp_nonce('main') }) }}
<script type="module" nonce="{{ csp_nonce('main') }}">import '{{ asset('bundles/mulertechpasskey/passkey.js') }}';</script>

The asset imports @simplewebauthn/browser by name, and a bare specifier is only resolvable against an import map: a page carrying the module import without importmap() fails on Failed to resolve module specifier "@simplewebauthn/browser". The empty entrypoint list renders the map and its preloads without pulling an application entrypoint into the page.

Usage

Sign-in page

<div data-passkey
     data-passkey-login-options-url="/passkey/login/options"
     data-passkey-login-url="/passkey/login"
     data-passkey-redirect-url="{{ path('app_home') }}"
     data-passkey-remember-me-selector="#remember_me_passkey"
     data-passkey-error-message="{{ 'passkey.error'|trans }}">
    {# No name attribute: this box is never submitted, the asset reads it to append ?_remember_me=1 #}
    <input type="checkbox" id="remember_me_passkey">
    <label for="remember_me_passkey">Stay signed in without asking for my passkey again</label>

    <button type="button" data-passkey-action="login">Sign in with a passkey</button>
    <p data-passkey-status hidden></p>
</div>

Symfony only issues a remember-me cookie when the request carries _remember_me. The authenticator of web-auth builds its RememberMeBadge without parameters and never inspects the JSON body, so the flag travels in the query string, which is what the asset does when the box is ticked.

Management page

mulertech_passkey_index renders the list and the "add a passkey" button. To place it inside your site, override the layout alone:

{# templates/bundles/MulerTechPasskeyBundle/passkey/layout.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
    {% for message in app.flashes('success') %}<div>{{ message }}</div>{% endfor %}
    {% block passkey_content %}{% endblock %}
{% endblock %}

Deleting a key adds a flash message. Render it where this layout puts it, unless your base template already does: otherwise the message waits for the next page that renders one, and the deletion looks like it did nothing.

Under a nonce-based CSP

The asset is a module served from 'self', so script-src 'self' covers it. It writes no inline style and installs no inline handler.

Testing

./vendor/bin/mtdocker test-ai