In Part 1 of this series, we explored the "holy grail" of modern authentication: a 100% passwordless application. We stripped away passwords, hashes and reset emails, replacing them with the cryptographic elegance of the WebAuthn API.

But the real world is rarely that clean. You have legacy users who trust their password managers more than their biometrics. You have corporate environments where security keys aren't yet standard. Most importantly, you have the "Transition Period" — that awkward phase where you need to support the old while aggressively pushing the new.

Today, we are building the Hybrid Model. We're going to create a single, intelligent login form that automatically detects if a user has a Passkey, triggers biometrics if available, but gracefully falls back to a traditional password when necessary.

We'll also look at Conditional Mediation (Passkey Autofill) — the "magic" UX that allows a user to log in simply by focusing an input field.

The Tech Stack

To follow this guide, you will need:

  • PHP 8.2+: Leveraging readonly classes and constructor promotion.
  • Symfony 7.4: Utilizing the latest Security Component improvements.
  • web-auth/webauthn-symfony-bundle: The industry standard for WebAuthn in Symfony.
  • Stimulus & AssetMapper: For a zero-Node.js frontend experience.

The UX Masterpiece: How It Works

Instead of confusing users with two separate login buttons ("Log in with Password" vs "Log in with Passkey"), we present them with a single, elegant input: Their Email Address.

  1. The user enters their email and clicks "Continue".
  2. Behind the scenes, our Symfony 7.4 backend does a lightning-fast check.
  3. If the user has a registered Passkey: We instantly trigger the native WebAuthn biometric prompt (FaceID, TouchID, Windows Hello).
  4. If the user relies on a password: The form gracefully expands to reveal the traditional password input field.

This is the exact flow used by tech giants like Google and GitHub and today, we are building it entirely with standard Symfony tools!

The Domain Model: Bridging Two Worlds

In our previous pure-passwordless setup, our User entity didn't even have a password field. To support a hybrid flow, we must re-introduce it, but as an optional credential. This allows for a tiered security model: a user can start with a simple password and later "upgrade" their account by registering a Passkey, which eventually becomes their primary (and most secure) way to log in.

By implementing the PasswordAuthenticatedUserInterface while keeping the password field nullable, we satisfy Symfony's security requirements for traditional login without forcing every user to have a legacy credential. This architectural choice is crucial for maintaining backwards compatibility while clearly signaling that Passkeys are the future-proof standard for the application.

// src/Entity/User.php
namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`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 = null;

    #[ORM\Column(length: 255, unique: true)]
    private ?string $userHandle = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $password = null;

    public function __construct()
    {
        // WebAuthn requires a persistent, non-identifying handle
        $this->userHandle = Uuid::v4()->toRfc4122();
    }

    // ... standard getters/setters

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function setPassword(?string $password): static
    {
        $this->password = $password;
        return $this;
    }

    public function eraseCredentials(): void
    {
        // Clear temporary sensitive data
    }

    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }
}

The Architecture of Choice: Flow Detection

The core of a great hybrid UX is Flow Detection. We don't want to show two forms. We want one input: "Enter your email." When the user clicks "Continue," our Stimulus controller hits a lightweight API endpoint to decide the next move. This prevents the "password field fatigue" where users are confronted with a complex form before they've even identified themselves.

Importantly, this endpoint is designed with "security through ambiguity" in mind. If an email is not found, we default the response to the password flow. This prevents malicious actors from using the API to verify which emails are registered in our system (user enumeration), while still allowing us to provide a tailored, progressive UI for our legitimate users.

The Flow API

// src/Controller/AuthController.php
namespace App\Controller;

use App\Repository\UserRepository;
use App\Repository\PublicKeyCredentialSourceRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class AuthController extends AbstractController
{
    #[Route('/api/auth/flow', name: 'app_api_auth_flow', methods: ['GET'])]
    public function apiAuthFlow(
        Request $request, 
        UserRepository $userRepo, 
        PublicKeyCredentialSourceRepository $credsRepo
    ): JsonResponse {
        $email = $request->query->get('email');
        $user = $userRepo->findOneBy(['email' => $email]);

        if (!$user) {
            // Treat non-existent users as password users to prevent enumeration
            return new JsonResponse(['flow' => 'password']);
        }

        $hasPasskeys = count($credsRepo->findAllForUserEntity($user->toWebauthnUser())) > 0;

        return new JsonResponse([
            'flow' => $hasPasskeys ? 'passkey' : 'password'
        ]);
    }
}

The Security Guard: HybridAuthenticator

While the webauthn-symfony-bundle handles Passkey verification automatically, we need a way to handle the traditional password fallback. Instead of using the built-in form_login, we implement a custom HybridAuthenticator. This allows us to treat different credential types (Passkeys vs. Passwords) as separate "badges" within a single unified authentication event, providing a much cleaner integration with the modern Symfony 7.4 Security component.

By using a custom authenticator, we can also ensure that both authentication methods share the exact same success and failure handlers. This means redirected dashboard URLs, flash messages and security logging are consistent across the entire app, regardless of whether the user used their fingerprint or a 20-character password to gain entry.

// src/Security/HybridAuthenticator.php
namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;

class HybridAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly AuthenticationSuccessHandler $successHandler,
        private readonly AuthenticationFailureHandler $failureHandler
    ) {}

    public function supports(Request $request): ?bool
    {
        // Only intercept standard POST login attempts with a password
        return $request->isMethod('POST') 
            && $request->getPathInfo() === '/login' 
            && $request->request->has('password');
    }

    public function authenticate(Request $request): Passport
    {
        $email = $request->request->getString('username');
        $password = $request->request->getString('password');

        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($password)
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewall): ?Response
    {
        return $this->successHandler->onAuthenticationSuccess($request, $token);
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return $this->failureHandler->onAuthenticationFailure($request, $exception);
    }
}

Symfony 7.4's authenticator system is incredibly flexible. We can configure our security.yaml to accept both form logins (passwords) and WebAuthn assertions on the same firewall!

security:
    password_hashers:
        App\Entity\User: 'auto'
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: app_user_provider

            custom_authenticator: App\Security\HybridAuthenticator

            webauthn:
                authentication:
                    routes:
                        options_path: /login/passkey/options
                        result_path: /login/passkey/result
                registration:
                    enabled: true
                    routes:
                        options_path: /register/passkey/options
                        result_path: /register/passkey/result
                success_handler: App\Security\AuthenticationSuccessHandler
                failure_handler: App\Security\AuthenticationFailureHandler

            logout:
                path: app_logout
    access_control:
        - { path: ^/dashboard, roles: ROLE_USER }

Frontend Magic: Conditional Mediation (Autofill)

This is where the application starts to feel truly modern. Conditional Mediation allows the browser to show a Passkey suggestion as soon as the user focuses the email field. This "zero-effort" login means that for many users, the login process consists of a single tap on their name in a browser popup, followed by a biometric scan.

To achieve this, we use the autocomplete="username webauthn" attribute in our HTML. This serves as a direct signal to the browser's credential manager. On the JavaScript side, the Stimulus connect() method is the perfect place to initiate a background "listen" for these autofill suggestions, allowing the page to remain interactive while the browser waits for the user's selection.

The Template

We need to add autocomplete="username webauthn" to our input. This is the signal to the browser.

{# templates/app/login.html.twig #}
<form data-controller="hybrid-login" data-action="submit->hybrid-login#submit">
    <input type="email" 
           name="username" 
           autocomplete="username webauthn"
           data-hybrid-login-target="email" 
           placeholder="Enter your email">
    
    <div data-hybrid-login-target="passwordContainer" class="d-none">
        <input type="password" name="password" data-hybrid-login-target="password">
    </div>

    <button type="submit" data-hybrid-login-target="continueButton">Continue</button>
</form>

The Stimulus Controller

In our connect() method, we check if the browser supports autofill. If it does, we start the WebAuthn ceremony immediately in the background.

// assets/controllers/hybrid_login_controller.js
import { Controller } from '@hotwired/stimulus';
import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser';

export default class extends Controller {
    async connect() {
        if (await browserSupportsWebAuthnAutofill()) {
            try {
                // Background listen for autofill
                const credential = await startAuthentication({
                    optionsJSON: await this.fetchOptions(),
                    useBrowserAutofill: true
                });
                await this.verifyResult(credential);
            } catch (e) {
                // Silent catch: user might just type manually
            }
        }
    }

    async submit(event) {
        event.preventDefault();
        const email = this.emailTarget.value;
        
        // 1. Check Flow
        const { flow } = await fetch(`/api/auth/flow?email=${email}`).then(r => r.json());

        if (flow === 'passkey') {
            await this.triggerPasskeyLogin(email);
        } else {
            this.showPasswordInput();
        }
    }
}

The "Upgrade" Path: Adding Passkeys from the Dashboard

The biggest challenge with Passkeys isn't the code; it's the adoption. You need to give your users a reason and a way to add biometrics to their existing password accounts. We've implemented a specialized PasskeySettingsController that allows already-logged-in users to safely bridge the gap between their password-based past and their biometric future without the friction of a full re-registration.

A key part of this flow is the use of excludeCredentials. By passing the user's existing credential IDs to the browser during this process, we ensure that the user isn't prompted to create a duplicate Passkey on the same device. This prevents "credential clutter" and ensures that the user's dashboard only shows unique, functional security keys, keeping the experience clean and manageable.

// src/Controller/PasskeySettingsController.php
#[Route('/dashboard/passkey/options', methods: ['POST'])]
public function options(): JsonResponse
{
    $user = $this->getUser();
    $userEntity = new PublicKeyCredentialUserEntity(
        $user->getEmail(),
        $user->getUserHandle(),
        $user->getEmail()
    );

    // Exclude existing keys so the browser doesn't offer duplicates
    $excludeDescriptors = array_map(
        fn ($source) => new PublicKeyCredentialDescriptor('public-key', $source->publicKeyCredentialId),
        $this->credsRepo->findAllForUserEntity($userEntity)
    );

    $options = $this->optionsFactory->create('default', $userEntity, $excludeDescriptors);
    $this->optionsStorage->store(Item::create($options, $userEntity));

    return new JsonResponse($this->serializer->serialize($options, 'json'), 200, [], true);
}

Technical Pitfalls & Verification

Binary Data and JSON

One of the most common issues in WebAuthn development is encoding. Credential IDs and challenges are raw binary data, which standard json_encode cannot handle.

We must use the base64url standard, which is specifically designed for safe transport in URLs and JSON without the problematic + or / characters found in standard Base64. Using the bundle's specialized serializer ensures this conversion happens correctly and consistently.

Verification Steps

  1. Password Registration: Create an account via the legacy /register/password route to establish your baseline "old world" user.
  2. Hybrid Login: Go to /login and enter the email. The system should correctly identify the user as a password-only user and reveal the password field.
  3. Upgrade: Log in with your password, navigate to the Dashboard and click "Add Passkey" to bridge the account into the biometric world.
  4. Autofill: Log out. Click the email field. Your browser should now offer the Passkey suggestion immediately, completing the circle of the hybrid experience.

Conclusion

The implementation of a hybrid authentication model in Symfony 7.4 represents a sophisticated balance between cutting-edge security and practical accessibility. By providing a unified interface that respects both legacy password habits and the move toward biometrics, you eliminate the friction that often kills new feature adoption. This approach doesn't just improve security; it builds trust with your users by meeting them where they are while clearly showing them a better, faster way forward.

Technically, we've seen that Symfony's modern Security component and the WebAuthn bundle provide a remarkably robust foundation for these complex flows. The ability to treat passwords and passkeys as complementary badges within the same ecosystem means that as a developer, you aren't fighting the framework to implement custom logic. Instead, you're utilizing standard, interoperable primitives to build a system that is as maintainable as it is secure.

Looking ahead, the goal is a web where authentication is so frictionless it becomes invisible. With Conditional Mediation and hybrid fallbacks, we are moving closer to that reality, where the burden of security shifts from the user's memory to the hardware in their pocket. By adopting these patterns today, you are future-proofing your application and ensuring that your users benefit from the highest standards of digital safety without sacrificing the convenience they've come to expect.

Source Code: You can find the full implementation and follow the project's progress on GitHub: [https://github.com/mattleads/PasskeysAuth]

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: