
Biometric Authentication in Symfony: Secure Your App with Two-Factor Authentication
Hey folks! Hope you’re doing well.
Today, we’re going to explore how to implement biometric authentication in a Symfony application. With growing security concerns, protecting user accounts with two-factor authentication (2FA) has become essential in modern applications.
In this tutorial, we’ll focus on using biometric authentication as a second layer of security. But before we dive into the implementation, let’s first understand what biometric authentication is and why it’s important.
What is biometric authentication?
Biometric authentication is a security process that verifies a user’s identity using unique biological characteristics. These can include fingerprints, facial recognition, voice patterns, or even retina scans. Unlike passwords or security tokens, biometric data is nearly impossible to replicate, making it a highly secure method of authentication.
In the context of web applications, biometric authentication is often used as a second factor (2FA) to enhance login security. It ensures that even if a password is compromised, only the authorized user can access the account using their biometric traits.
Why is biometric authentication important?
In today’s digital world, traditional passwords are no longer enough to protect user accounts. Many users reuse weak passwords across multiple sites, making them easy targets for hackers through phishing, brute-force attacks, or data breaches.
Biometric authentication adds an extra layer of security by verifying something the user is, not just something they know. Since biometric traits like fingerprints or facial patterns are unique to each person, they are much harder to steal or fake.
By combining biometric authentication with traditional login methods (like a password), we create a stronger and more secure login system. This is especially important for applications that handle sensitive data, financial transactions, or personal information.
Implementing Biometric authentication in Symfony:
Implementing biometric authentication in Symfony is a great way to enhance the security of your application by adding a second layer of verification during user login. This method relies on unique biological traits—such as fingerprints or facial recognition—ensuring that only the authorized user can access their account.
In this guide, we’ll walk through how to set up biometric authentication as a form of two-factor authentication (2FA) using Symfony. We’ll leverage WebAuthn (Web Authentication API), a modern and secure standard supported by most browsers and devices, to enable fingerprint or Face ID login seamlessly within your Symfony application.
Install the new app and required packages:
If you already have the Symfony CLI installed, you just need to run the following command. If you don’t have the Symfony CLI, you can download it from here.
symfony new biometric-auth-app --webapp --version=stable
Once you run this command, Symfony will create a new full web application using the current stable version, which is Symfony 7.2. I highly recommend using PHP 8.4 so you can take advantage of the latest features.
Creating Required Entities and Other Files
Before we start creating any files, let me walk you through the features we’ll be developing in this tutorial.
Biometric authentication is something that users typically don’t set up during registration or initial login. So, we’ll implement two types of two-factor authentication (2FA) methods:
- Email-based verification – This will be the default 2FA method after login.
- Biometric authentication – This can be enabled or disabled by the user from the account settings page.
From the settings page, users will have full control to enable or disable both email authentication and biometric authentication. It’s important to understand this flow clearly before we dive into coding, as it helps clarify why each part is necessary.
We’ll also have a clean and user-friendly UI for this tutorial. I’ve used the SB Admin Bootstrap theme because it’s easy to install and customize. However, you’re free to use any Bootstrap theme or UI framework you prefer.
User Entity
<?php
namespace App\Entity;
use App\Entity\Enum\UserRole;
use App\Repository\UserRepository;
use Carbon\CarbonImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255)]
private string $email;
#[ORM\Column(length: 255)]
private string $password;
#[ORM\Column(options: ['default' => UserRole::USER])]
private UserRole $role;
#[ORM\Column]
private bool $enabled = true;
#[ORM\Column]
private bool $enable2fa = true;
#[ORM\Column]
private bool $enableBioMetricsFor2fa = false;
#[ORM\Column]
private CarbonImmutable $createdAt;
#[ORM\PrePersist]
public function prePersist(): void
{
$this->createdAt = CarbonImmutable::now();
}
public function getId(): int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getRole(): UserRole
{
return $this->role;
}
public function setRole(UserRole $role): self
{
$this->role = $role;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
public function isEnable2fa(): bool
{
return $this->enable2fa;
}
public function setEnable2fa(bool $enable2fa): static
{
$this->enable2fa = $enable2fa;
return $this;
}
public function getCreatedAt(): CarbonImmutable
{
return $this->createdAt;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getRoles(): array
{
return [$this->role->value];
}
public function eraseCredentials(): void
{
return;
}
public function getUserIdentifier(): string
{
return $this->email;
}
public function isEnableBioMetricsFor2fa(): bool
{
return $this->enableBioMetricsFor2fa;
}
public function setEnableBioMetricsFor2fa(bool $enableBioMetricsFor2fa): void
{
$this->enableBioMetricsFor2fa = $enableBioMetricsFor2fa;
}
}
If you’ve noticed, we’ve used an Enum for the user role. Thanks to Symfony 7 and the updated DBAL and ORM packages, this feature is now supported natively. In earlier Symfony versions, using enums required several additional configurations.
If you’re working with an older version of Symfony and want to implement enums, don’t worry—we already have a blog post covering that topic. You can read it here.
User Role Enum
<?php
namespace App\Entity\Enum;
use Elao\Enum\Attribute\EnumCase;
use Elao\Enum\ExtrasTrait;
use Elao\Enum\ReadableEnumInterface;
use Elao\Enum\ReadableEnumTrait;
enum UserRole: string implements ReadableEnumInterface
{
use ReadableEnumTrait;
use ExtrasTrait;
#[EnumCase('Super Admin', extras: ['badge_class' => 'bg-primary'])]
case SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
#[EnumCase('Admin', extras: ['badge_class' => 'bg-success'])]
case ADMIN = 'ROLE_ADMIN';
#[EnumCase('User', extras: ['badge_class' => 'bg-info'])]
case USER = 'ROLE_USER';
public function getBadgeClass(): string
{
return $this->getExtra('badge_class', true);
}
}
you can ignore that extra trait for now I have used it for nice UI.
Biometric Data Entity
We’ll store the user’s biometric device registration details in this entity. You can name the entity something like UserDevice
or any name that fits your project structure. The entity will require the following fields:
<?php
namespace App\Entity;
use App\Repository\BioMetricDataRepository;
use Carbon\CarbonImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BioMetricDataRepository::class)]
class BioMetricData
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(type: 'blob_string')]
private string $data;
#[ORM\Column(type: 'string')]
private string $credentialId;
#[ORM\Column]
private CarbonImmutable $createdTime;
#[ORM\Column(nullable: true)]
private ?CarbonImmutable $lastUsedTime = null;
public function __construct()
{
$this->createdTime = CarbonImmutable::now();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getData(): string
{
return $this->data;
}
public function setData(string $data): static
{
$this->data = $data;
return $this;
}
public function getCredentialId(): string
{
return $this->credentialId;
}
public function setCredentialId(string $credentialId): static
{
$this->credentialId = $credentialId;
return $this;
}
public function getCreatedTime(): CarbonImmutable
{
return $this->createdTime;
}
public function setCreatedTime(CarbonImmutable $createdTime): static
{
$this->createdTime = $createdTime;
return $this;
}
public function getLastUsedTime(): ?CarbonImmutable
{
return $this->lastUsedTime;
}
public function setLastUsedTime(?CarbonImmutable $lastUsedTime): static
{
$this->lastUsedTime = $lastUsedTime;
return $this;
}
}
Yes, we’ll need these entities. I recommend creating them using the Symfony CLI, as it will automatically generate the corresponding repository classes for you. You won’t need to copy the code manually from here, but you can certainly use the column suggestions provided above as a reference.
Once you’ve created the entities, run the migration command to generate the database tables. Before doing that, make sure you’ve set up your database credentials in the .env
file. I won’t be covering that part here, as it’s something you should already be familiar with.
Building Login feature:
The login feature is quite easy to generate in Symfony—you don’t need to write much manually.
Just run the following command in your terminal, and Symfony will generate the complete login functionality for you:
php bin/console make:auth
The previous command might be deprecated in the latest Symfony versions. Instead, you can use the make:security
command, which serves the same purpose. This command will generate the necessary controller, login form, and other required files to handle authentication.
We don’t need to use a custom authenticator for this implementation. So feel free to remove the AppAuthenticator
class (or whatever name you gave to your authenticator). Instead, you can simply follow the configuration shown in the security.yaml
file below.
security.yaml
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
role_hierarchy:
ROLE_SUPER_ADMIN: ROLE_ADMIN
ROLE_ADMIN: ROLE_USER
providers:
user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: user_provider
user_checker: security.user_checker.chain.main
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
You can ignore the user_checker
part in the security.yaml
file—this tutorial does not cover it. If you’re interested in learning more about it, feel free to leave a comment below, and I’d be happy to write a separate blog post on the topic.
Also, if you’d like to explore new Symfony features or have specific topics in mind, let me know in the comments!
Let’s create some fixtures to load default users into the system.
To create fixtures, you need to install the doctrine/doctrine-fixtures-bundle
package. Make sure to install it as a development dependency.
Once you’ve installed the package, you’ll find an AppFixtures
file inside the src/DataFixtures
directory. You can use the following code as a reference to create a default user:
<?php
namespace App\DataFixtures;
use App\Entity\Enum\UserRole;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class AppFixtures extends Fixture
{
public function __construct(private readonly UserPasswordHasherInterface $userPasswordHasher)
{
}
public function load(ObjectManager $manager): void
{
$user = new User();
$user->setEmail('superadmin@system.com');
$password = $this->userPasswordHasher->hashPassword($user, 'Admin#@123');
$user->setPassword($password);
$user->setName('Super Admin System');
$user->setEnabled(true);
$user->setRole(UserRole::SUPER_ADMIN);
$user->setEnable2fa(false);
$user->setEnableBioMetricsFor2fa(false);
$manager->persist($user);
$manager->flush();
}
}
Once you’ve set this up, you can load the users into the database by running the following command:
php bin/console doctrine:fixtures:load
Once the default user is loaded into your system, simply start the Symfony server and visit the login URL. I recommend creating a dashboard page so that the user is redirected there after a successful login.
Once that’s done, you’ll be automatically redirected to the dashboard after login.
But wait—aren’t we implementing two-factor authentication? Why didn’t any 2FA page appear? That’s because we haven’t created it yet. Since we’re using Symfony’s default form login method, we’ll need to handle the 2FA logic using event listeners.
But before we proceed, let me give you a brief overview of what an event listener is and how it works.
What is an Event Listener in Symfony?
In Symfony, an event listener is a way to hook into the application’s lifecycle and perform actions when certain events occur. Symfony dispatches events at different stages—for example, during user login, request handling, or response generation.
You can create an event listener to listen for specific events (like LoginSuccessEvent
or ResponseEvent
) and execute your custom logic when those events are triggered.
For two-factor authentication, we’ll use an event listener to intercept the login success event. From there, we can check if the user has 2FA enabled and redirect them to the appropriate verification page.
I’ll quickly share the code for the redirection here. It’s straightforward and easy to understand, so I won’t go into detailed explanations.
<?php
namespace App\Security\EventSubscriber;
use App\Controller\SecurityController;
use App\Entity\User;
use App\Helper\MailerHelper;
use Symfony\Bundle\FrameworkBundle\Controller\RedirectController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Bundle\SecurityBundle\Security\FirewallConfig;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
class UserAuthenticationSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly Security $security,
private readonly TokenStorageInterface $tokenStorage,
private readonly MailerHelper $mailerHelper,
private RedirectController $redirectController,
private UrlGeneratorInterface $urlGenerator,
) {
}
public static function getSubscribedEvents(): array
{
return [
LoginSuccessEvent::class => 'onLoginSuccess',
KernelEvents::CONTROLLER => 'onKernelController',
];
}
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $this->tokenStorage->getToken()?->getUser();
$request = $event->getRequest();
if (!$user instanceof User) {
return;
}
$session = $request->getSession();
$session->set('2fa_verify', false);
if ($user->isEnable2fa() && $user->isEnableBioMetricsFor2fa()) {
$event->setResponse(new RedirectResponse(
$this->urlGenerator->generate('app_2fa_choices')
));
} elseif ($user->isEnable2fa()) {
if (!$session->get('2fa_code_sent')) {
$this->mailerHelper->sendTotpMail($user);
$session->set('2fa_code_sent', true);
}
$event->setResponse(new RedirectResponse(
$this->urlGenerator->generate('app_2fa')
));
} elseif ($user->isEnableBioMetricsFor2fa()) {
$event->setResponse(new RedirectResponse(
$this->urlGenerator->generate('app_biometric_auth')
));
} else {
$event->setResponse(new RedirectResponse(
$this->urlGenerator->generate('dashboard')
));
}
$event->stopPropagation();
}
public function onKernelController(ControllerEvent $event): void
{
$currentUser = $this->tokenStorage->getToken()?->getUser();
$request = $event->getRequest();
if (!$currentUser instanceof User) {
return;
}
$twoFactorCodeSent = $request->getSession()->get('2fa_code_sent');
$twoFactorVerification = $request->getSession()->get('2fa_verify');
if ($currentUser->isEnableBioMetricsFor2fa() || $currentUser->isEnable2fa()) {
if (!in_array($request->attributes->get('_route'),
[
'app_2fa_choices',
'app_2fa',
'app_2fa_verify',
'app_biometric_auth',
'bio_metrics_get_args',
'app_biometrics_check_biometric_registration',
], true))
{
if (!$twoFactorVerification) {
if ($currentUser->isEnableBioMetricsFor2fa() && $currentUser->isEnable2fa()) {
$response = $this->redirectController->redirectAction($request, 'app_2fa_choices');
$event->setController(fn () => $response);
} elseif ($currentUser->isEnable2fa()) {
$response = $this->redirectController->redirectAction($request, 'app_2fa');
$event->setController(fn () => $response);
} else {
$response = $this->redirectController->redirectAction($request, 'app_biometric_auth');
$event->setController(fn () => $response);
}
}
} else {
if ($currentUser->isEnable2fa() && !$twoFactorCodeSent && $request->attributes->get('_route') === 'app_2fa') {
$this->mailerHelper->sendTotpMail($currentUser);
$request->getSession()->set('2fa_code_sent', true);
}
if ($twoFactorVerification) {
$response = $this->redirectController->redirectAction($request, 'dashboard');
$event->setController(fn () => $response);
}
}
}
}
}
Handling Two-Factor Redirection with an Event Subscriber
To manage two-factor authentication (2FA) flow, we’ve created a custom event subscriber called UserAuthenticationSubscriber
. It listens to login and controller events and redirects users based on their 2FA settings.
What It Does:
- onLoginSuccess(LoginSuccessEvent)
This method is triggered when a user successfully logs in. Based on the user’s settings:- If both email 2FA and biometric 2FA are enabled, the user is redirected to a choice page (
app_2fa_choices
). - If only email 2FA is enabled, a verification code is emailed and the user is sent to the 2FA page (
app_2fa
). - If only biometric 2FA is enabled, the user is redirected to biometric verification (
app_biometric_auth
). - If no 2FA is enabled, they go straight to the dashboard.
- If both email 2FA and biometric 2FA are enabled, the user is redirected to a choice page (
- onKernelController(ControllerEvent)
This method ensures that users who haven’t completed 2FA can’t access other routes. It:- Redirects the user back to the appropriate 2FA page if verification is incomplete.
- Prevents repeated code emails by checking session flags.
- Redirects to the dashboard if the user has already verified.
Why It Matters:
This approach ensures that 2FA is enforced after login but before accessing protected routes, without needing a custom authenticator. It uses Symfony events to inject logic cleanly and keeps the flow secure and user-friendly.
Here’s a simple flowchart to help you understand what’s happening in the background during the two-factor authentication process:

For biometric authentication, we’re going to use a PHP library called lbuchs/webauthn
. You can easily install it using the command below:
composer require lbuchs/webauthn
I’ll be writing the complete code here, but I won’t explain every line in detail—otherwise, this blog would become extremely long.
We’ll be creating the following controllers, helpers, and Twig templates as part of this implementation:
- Controllers:
SecurityController
,BiometricDataController
,SettingController
- Helpers:
BiometricDataHelper
,MailerHelper
,TotpHelper
- Twig Templates:
2fa_choices.html.twig
,2fa_verify.html.twig
,biometrics_auth.html.twig
,login.html.twig
,setting/index.html.twig
,email/totp_email.html.twig
I’ve used a variety of components to build this system, so I’ll be pushing the full code to GitHub. You’ll be able to download and explore it from there.
SecurityController
<?php
namespace App\Controller;
use App\Entity\User;
use App\Helper\MailerHelper;
use App\Helper\TotpHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
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;
#[IsGranted('PUBLIC_ACCESS')]
class SecurityController extends AbstractController
{
public function __construct(private readonly TotpHelper $totpHelper, private readonly MailerHelper $mailerHelper)
{
}
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('dashboard');
}
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
#[Route('/2fa', name: 'app_2fa', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function twoFactorAuth(): Response
{
return $this->render('security/2fa_verify.html.twig');
}
#[Route('/2fa/choices', name: 'app_2fa_choices', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function twoFactorAuthChoices(): Response
{
return $this->render('security/2fa_choices.html.twig');
}
#[Route('/2fa/verify', name: 'app_2fa_verify', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function twoFactorAuthVerify(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$totpCode = implode('', $request->get('code'));
$isValid = $this->totpHelper->verifyOtp($user, $totpCode);
if ($isValid) {
$request->getSession()->set('2fa_verify', true);
return $this->redirectToRoute('dashboard');
} else {
return $this->redirectToRoute('app_2fa');
}
}
#[Route('/bio-metrics-auth', name: 'app_biometric_auth', methods: ['GET', 'POST'])]
public function biometricsAuth(): Response
{
return $this->render('security/biometrics_auth.html.twig');
}
}
Key Features:
/login
(app_login)
Renders the login form and shows any login errors. Redirects authenticated users to the dashboard./logout
(app_logout)
This method is intentionally left blank—Symfony handles the logout process automatically through the firewall./2fa
(app_2fa)
Renders the TOTP (email code-based) 2FA verification page./2fa/choices
(app_2fa_choices)
Shows options for users to choose between email 2FA and biometric 2FA./2fa/verify
(app_2fa_verify)
Verifies the submitted TOTP code using theTotpHelper
. If valid, 2FA is marked as complete in the session and the user is redirected to the dashboard./bio-metrics-auth
(app_biometric_auth)
Renders the biometric authentication page.
BioMetricsController
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\BioMetricData;
use App\Entity\User;
use App\Helper\BioMetricsHelper;
use App\Repository\BioMetricDataRepository;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Throwable;
#[Route('/bio-metrics')]
#[IsGranted('ROLE_USER')]
class BioMetricsController extends AbstractController
{
public function __construct(private readonly BioMetricsHelper $bioMetricsHelper, private readonly BioMetricDataRepository $bioMetricDataRepository)
{
}
#[Route('/create-args', name: 'bio_metrics_create_args')]
public function index(): JsonResponse
{
/** @var User $currentUser*/
$currentUser = $this->getUser();
try {
$createdArgs = $this->bioMetricsHelper->createArgsAndStoreChallengeIntoSession((string) $currentUser->getId(), $currentUser->getEmail(), $currentUser->getName(), 30);
} catch (Throwable $e) {
return $this->json([
'error' => $e->getMessage(),
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->json([
'status' => true,
'createdArgs' => $createdArgs,
]);
}
#[Route('/get-args', name: 'bio_metrics_get_args')]
public function getArgs(): JsonResponse
{
/** @var User $currentUser*/
$currentUser = $this->getUser();
try {
$getArgs = $this->bioMetricsHelper->getArgsForUser($currentUser);
} catch (Throwable $e) {
return $this->json([
'success' => false,
'status' => 'error',
'message' => $e->getMessage(),
]);
}
return $this->json([
'success' => true,
'status' => 'ok',
'getArgs' => $getArgs,
]);
}
#[Route('/process-create', name: 'bio_metrics_process_create')]
public function processGetRequest(Request $request): JsonResponse
{
$success = false;
$errorMessage = null;
/** @var User $currentUser */
$currentUser = $this->getUser();
try {
$bodyData = json_decode($request->getContent(), true);
if (!isset($bodyData['clientDataJSON'], $bodyData['attestationObject'])) {
throw new InvalidArgumentException("Missing WebAuthn credential data.");
}
$clientDataJSON = base64_decode($bodyData['clientDataJSON']);
$attestationObject = base64_decode($bodyData['attestationObject']);
$this->bioMetricsHelper->processCreateRequest($clientDataJSON, $attestationObject, $currentUser);
$success = true;
} catch (Throwable $e) {
$errorMessage = $e->getMessage();
}
return $this->json([
'success' => $success,
'errorMessage' => $errorMessage,
]);
}
#[Route('/check-bio-metric-registration', name: 'app_biometrics_check_biometric_registration')]
public function checkBioMetricRegistration(Request $request): JsonResponse
{
/** @var User $currentUser */
$currentUser = $this->getUser();
try {
$bodyData = json_decode($request->getContent(), true);
if (!isset($bodyData['clientDataJSON']) || !isset($bodyData['authenticatorData']) || !isset($bodyData['signature']) || !isset($bodyData['id'])) {
throw new InvalidArgumentException("Missing WebAuthn credential data.");
}
$clientDataJSON = base64_decode($bodyData['clientDataJSON']);
$authenticatorData = base64_decode($bodyData['authenticatorData']);
$signature = base64_decode($bodyData['signature']);
$id = base64_decode($bodyData['id']);
$biometricData = $this->bioMetricDataRepository->findOneBy([
'user' => $currentUser,
'credentialId' => bin2hex($id),
]);
if (!$biometricData instanceof BioMetricData) {
throw new InvalidArgumentException("Biometric data not found.");
}
$data = $biometricData->getData();
if (!$data) {
throw new InvalidArgumentException("Biometric data not found.");
}
$data = unserialize($data);
$credentialPublicKey = $data->credentialPublicKey;
if (!$credentialPublicKey) {
throw new InvalidArgumentException("Biometric data not found.");
}
$this->bioMetricsHelper->processGetRequest($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $biometricData);
$request->getSession()->set('2fa_verify', true);
return $this->json([
'status' => 'ok',
'message' => 'Biometric data verified successfully',
]);
} catch (Throwable $e) {
$response = [
'status' => 'error',
'message' => 'Failed to retrieve bio metric data' . $e->getMessage(),
];
}
return $this->json($response);
}
}
This controller manages the entire flow for biometric (WebAuthn) 2FA: from registering the device to verifying it during login.
Key Endpoints:
/bio-metrics/create-args
Generates WebAuthn registration challenge and stores it in session. ReturnspublicKey
options used to initiate biometric registration in the browser./bio-metrics/get-args
Returns authentication challenge for verifying existing biometric credentials during login./bio-metrics/process-create
Handles the POST request sent after a user registers their biometric device. It decodes WebAuthn data and saves it for the user./bio-metrics/check-bio-metric-registration
Verifies the biometric credential during login using WebAuthn challenge-response. If verification passes, it marks 2FA as complete by updating the session.
SettingController
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
class SettingController extends AbstractController
{
public function __construct(private readonly UserRepository $userRepository)
{
}
#[Route('/settings', name: 'settings_index')]
public function index(): Response
{
return $this->render('setting/index.html.twig');
}
#[Route('/settings/manage-two-factor-auth', name: 'settings_manage_two_factor_auth')]
public function manageTwoFactorAuth(Request $request): JsonResponse
{
$csrfToken = $request->request->getString('_token');
$status = false;
$errorMessage = null;
$enabled = $request->request->getBoolean('2fa');
if (!$this->isCsrfTokenValid('manage_two_factor_auth', $csrfToken)) {
$errorMessage = 'Invalid CSRF token';
} else {
try {
/** @var User $user */
$user = $this->getUser();
$user->setEnable2fa($enabled);
$this->userRepository->saveUser($user);
$status = true;
} catch (\Throwable $e) {
$errorMessage = 'Failed to manage two factor auth, ' . $e->getMessage();
}
}
return $this->json([
'status' => $status,
'errorMessage' => $errorMessage
]);
}
#[Route('/settings/manage-bio-metrics', name: 'settings_manage_bio_metrics')]
public function manageBioMetrics(Request $request): JsonResponse
{
$csrfToken = $request->request->getString('_token');
$status = false;
$errorMessage = null;
$enabled = $request->request->getBoolean('bio_metrics');
if (!$this->isCsrfTokenValid('bio_metrics_auth', $csrfToken)) {
$errorMessage = 'Invalid CSRF token';
} else {
try {
/** @var User $user */
$user = $this->getUser();
$user->setEnableBioMetricsFor2fa($enabled);
$this->userRepository->saveUser($user);
$status = true;
} catch (\Throwable $e) {
$errorMessage = 'Failed to manage two factor auth, ' . $e->getMessage();
}
}
return $this->json([
'status' => $status,
'errorMessage' => $errorMessage
]);
}
}
This controller allows users to manage their two-factor authentication preferences from the settings page.
Key Routes:
/settings
(settings_index)
Renders the settings page where users can manage their 2FA options./settings/manage-two-factor-auth
Enables or disables email-based 2FA based on a toggle input. It validates the CSRF token, updates the user’s preference, and returns a JSON response./settings/manage-bio-metrics
Similar to the above, but for biometric authentication. It updates whether biometric 2FA should be enabled or not for the logged-in user.
BiometricDataHelper
<?php
declare(strict_types=1);
namespace App\Helper;
use App\Entity\User;
use App\Entity\BioMetricData;
use App\Repository\BioMetricDataRepository;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use lbuchs\WebAuthn\WebAuthn;
use lbuchs\WebAuthn\WebAuthnException;
use RuntimeException;
use stdClass;
use Symfony\Component\HttpFoundation\RequestStack;
use Throwable;
readonly class BioMetricsHelper
{
public function __construct(
private RequestStack $requestStack,
private EntityManagerInterface $entityManager,
private BioMetricDataRepository $bioMetricDataRepository,
) {
}
/**
* @throws WebAuthnException
*/
public function createArgsAndStoreChallengeIntoSession(
string $userId,
string $userIdentifier,
string $userDisplayName,
int $timout,
): StdClass {
$webauthn = $this->getWebAuthn();
$createdArgs = $webauthn->getCreateArgs($userId, $userIdentifier, $userDisplayName, $timout);
$this->requestStack->getSession()->set('webauthn_challenge', $webauthn->getChallenge());
return $createdArgs;
}
public function processCreateRequest(
string $clientDataJSON,
string $attestationObject,
User $user,
): void {
try {
$webAuthn = $this->getWebAuthn();
$challenge = $this->requestStack->getSession()->get('webauthn_challenge');
if (!$challenge) {
throw new InvalidArgumentException("Challenge not found in session.");
}
$data = $webAuthn->processCreate($clientDataJSON, $attestationObject, $challenge);
$bioMetricsData = new BioMetricData();
$bioMetricsData->setUser($user);
$bioMetricsData->setLastUsedTime(CarbonImmutable::now());
$bioMetricsData->setCreatedTime(CarbonImmutable::now());
$bioMetricsData->setData(serialize($data));
$bioMetricsData->setCredentialId(bin2hex($data->credentialId));
$this->entityManager->persist($bioMetricsData);
$this->entityManager->flush();
} catch (Throwable $e) {
throw new RuntimeException('Failed process create request, ' . $e->getMessage());
}
}
public function getArgsForUser(User $currentUser): StdClass
{
try {
$userCredentials = $this->bioMetricDataRepository->getCredentialsForUser($currentUser);
$ids = [];
foreach ($userCredentials as $userCredential) {
$ids[] = hex2bin($userCredential);
}
if (empty($ids)) {
throw new RuntimeException('no registrations in session.');
}
$webAuthn = $this->getWebAuthn();
$getArgs = $webAuthn->getGetArgs($ids);
$this->requestStack->getSession()->set('webauthn_challenge', $webAuthn->getChallenge());
return $getArgs;
} catch (Throwable $e) {
throw new RuntimeException($e->getMessage());
}
}
public function processGetRequest(
string $clientDataJSON,
string $authenticatorData,
string $signature,
string $credentialPublicKey,
BioMetricData $bioMetricData,
): void {
try {
$challenge = $this->requestStack->getSession()->get('webauthn_challenge');
if (!$challenge) {
throw new InvalidArgumentException("Challenge not found in session.");
}
$webAuthn = $this->getWebAuthn();
$webAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge);
$bioMetricData->setLastUsedTime(CarbonImmutable::now());
$this->entityManager->persist($bioMetricData);
$this->entityManager->flush();
} catch (Throwable $e) {
throw new RuntimeException('Failed process get request, ' . $e->getMessage());
}
}
/**
* @throws WebAuthnException
*/
private function getWebAuthn(): WebAuthn
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
throw new RuntimeException('Invalid Request');
}
$rpId = 'localhost';
$formats = ['android-key', 'android-safetynet', 'fido-u2f', 'none', 'packed'];
return new WebAuthn('My Application', $rpId, $formats);
}
}
This helper encapsulates all the logic needed to handle WebAuthn-based biometric authentication using the lbuchs/webauthn
PHP library.
Key Responsibilities:
createArgsAndStoreChallengeIntoSession()
Generates WebAuthn registration options (createArgs
) for the browser and stores the challenge in the session.processCreateRequest()
Handles the data returned after biometric registration. It validates the response, serializes the WebAuthn credentials, and saves them to the database linked to the current user.getArgsForUser()
Fetches stored credential IDs for the logged-in user and generates authentication challenge options (getArgs
) for the browser.processGetRequest()
Verifies the biometric response during login. If successful, it updates the last used time and completes the biometric verification.getWebAuthn()
(private)
Initializes the WebAuthn object with Relying Party (RP) info, supported formats, and request context.
MailerHelper
<?php
namespace App\Helper;
use App\Entity\User;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Throwable;
readonly class MailerHelper
{
public function __construct(private MailerInterface $mailer, private TotpHelper $totpHelper)
{
}
public function sendTestMail(User $user): void
{
$this->sendMail('Test Mail', $user->getEmail(), 'emails/test_email.html.twig', ['user' => $user]);;
}
public function sendTotpMail(User $user): void
{
$totpCode = $this->totpHelper->generateOtp($user);
$this->sendMail('2FA Code', $user->getEmail(), 'emails/totp_email.html.twig', ['user' => $user, 'otp' => $totpCode, 'validForMinutes' => 10]);;
}
private function sendMail(string $subject, string $to, string $emailTemplate, array $parameters = []): void
{
$mail = new TemplatedEmail()
->from('systemadmin@biometricapp.com')
->addTo($to)
->subject($subject)
->htmlTemplate($emailTemplate)
->context($parameters);
try {
$this->mailer->send($mail);
} catch (Throwable $e) {
throw new RuntimeException('Failed to send e-mail, '.$e->getMessage());
}
}
}
This helper handles all email functionality in the app, especially for sending TOTP codes as part of two-factor authentication.
Key Methods:
sendTestMail()
Sends a basic test email to the specified user using a Twig template.sendTotpMail()
Generates a TOTP code usingTotpHelper
and sends it to the user’s email using thetotp_email.html.twig
template. The email also includes code validity information (e.g., valid for 10 minutes).sendMail()
(private)
A reusable method that prepares and sends a templated email with dynamic parameters using Symfony’sMailerInterface
.
TotpHelper
<?php
namespace App\Helper;
use App\Entity\User;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
readonly class TotpHelper
{
public function __construct(private ParameterBagInterface $parameterBag)
{
}
public function generateOtp(User $user): string
{
$key = $this->parameterBag->get('OTP_MASTER_KEY');
$window = floor(time() / 600);
$data = $user->getEmail() . '|' . $window;
$hash = hash_hmac('sha1', $data, $key);
$code = hexdec(substr($hash, -6)) % 1000000;
return str_pad((string) $code, 6, '0', STR_PAD_LEFT);
}
public function verifyOtp(User $user, string $submittedOtp): bool
{
$key = $this->parameterBag->get('OTP_MASTER_KEY');
$currentWindow = floor(time() / 600);
for ($i = -1; $i <= 1; $i++) {
$window = $currentWindow + $i;
$data = $user->getEmail() . '|' . $window;
$hash = hash_hmac('sha1', $data, $key);
$expected = str_pad((string)(hexdec(substr($hash, -6)) % 1000000), 6, '0', STR_PAD_LEFT);
if (hash_equals($expected, $submittedOtp)) {
return true;
}
}
return false;
}
}
This helper generates and verifies TOTP-like codes (One-Time Passwords) based on the user’s email and a shared secret. It’s a custom implementation that mimics how TOTP works without external dependencies.
Key Methods:
generateOtp()
Generates a 6-digit numeric code that changes every 10 minutes.
It uses HMAC-SHA1 with a secret key (OTP_MASTER_KEY
) and the user’s email plus a time-based window.verifyOtp()
Validates the submitted OTP by checking it against the current and nearby time windows (±1) for tolerance.
This ensures the code remains valid even with minor time sync differences.
2fa_choices.html.twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Two-Factor Authentication</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.otp-input {
width: 3rem;
height: 3rem;
text-align: center;
font-size: 1.5rem;
margin: 0 0.25rem;
}
</style>
</head>
<body class="bg-light">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet" />
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow rounded">
<div class="card-body text-center">
<h4 class="card-title mb-4">Choose 2FA Method</h4>
<a href="{{ path('app_2fa') }}" class="btn btn-outline-primary btn-lg w-100 mb-3 d-flex align-items-center justify-content-center gap-2">
<i class="fas fa-envelope"></i>
Use Email Code
</a>
<a href="{{ path('app_biometric_auth') }}" class="btn btn-outline-success btn-lg w-100 d-flex align-items-center justify-content-center gap-2">
<i class="fas fa-fingerprint"></i>
Use Biometric Authentication
</a>
<a href="{{ path('app_logout') }}" class="btn btn-link mt-4 text-danger">Logout</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
2fa_verify.html.twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Two-Factor Authentication</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.otp-input {
width: 3rem;
height: 3rem;
text-align: center;
font-size: 1.5rem;
margin: 0 0.25rem;
}
</style>
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow rounded">
<div class="card-body">
<h4 class="card-title text-center mb-4">Enter 2FA Code</h4>
<form method="post" action="{{ path('app_2fa_verify') }}">
<div class="d-flex justify-content-center mb-3">
{% for i in 0..5 %}
<input type="text" name="code[]" maxlength="1" pattern="[0-9]*"
class="form-control otp-input" inputmode="numeric" required>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">Verify</button>
{% if app.request.query.has('error') %}
<button type="button" class="btn btn-warning w-100 mt-2">Resend Code</button>
{% endif %}
</form>
<p class="text-muted text-center mt-3">Code valid for 10 minutes.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Auto-focus and jump to next input
document.querySelectorAll('.otp-input').forEach((input, index, inputs) => {
input.addEventListener('input', () => {
if (input.value.length === 1 && index < inputs.length - 1) {
inputs[index + 1].focus();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && input.value === '' && index > 0) {
inputs[index - 1].focus();
}
});
});
</script>
</body>
</html>
biometrics_auth.html.twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Two-Factor Authentication</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet" />
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow rounded text-center">
<div class="card-body">
<h5 class="mb-4">Biometric Authentication</h5>
<button id="fingerprint-auth" class="btn btn-outline-primary rounded-circle p-2 border-2" style="font-size: 3rem;">
<i class="fa fa-fingerprint"></i>
</button>
<p class="mt-4 mb-0 text-muted">Click fingerprint to authenticate</p>
<a href="{{ path('app_logout') }}" class="btn btn-link mt-3 text-danger">Logout</a>
</div>
</div>
</div>
</div>
</div>
<script src="{{ asset('assets/js/common.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('fingerprint-auth').addEventListener('click', function () {
if (!window.fetch || !navigator.credentials || !navigator.credentials.get) {
window.alert('Browser not supported.');
return;
}
let biometricDataGetArgsURL = "{{ path('bio_metrics_get_args') }}";
let processBiometricDataCheck = "{{ path('app_biometrics_check_biometric_registration') }}";
fetch(biometricDataGetArgsURL, { method: 'POST', cache: 'no-cache' })
.then((response) => response.json())
.then((data) => {
if (!data.success) {
throw new Error('WebAuthn arguments retrieval failed');
}
return recursiveBase64StrToArrayBuffer(data.getArgs);
})
.then((getCredentialArgs) => navigator.credentials.get(getCredentialArgs))
.then((cred) => {
return {
id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
signature: cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null,
};
})
.then((authResponse) => {
fetch(processBiometricDataCheck, {
method: 'POST',
body: JSON.stringify(authResponse),
}).then((response) => {
return response.json();
}).then((data) => {
if (data.status === 'ok') {
window.location.reload();
} else {
window.alert('Biometric authentication failed');
}
});
});
});
});
</script>
</body>
</html>
This template renders a clean, Bootstrap-styled page where users can authenticate using their biometric device (fingerprint, Face ID, etc.).
Key Elements:
- Fingerprint Button:
A large fingerprint icon button triggers the biometric login using the WebAuthn API. - JavaScript Logic:
- Calls your Symfony backend (
bio_metrics_get_args
) to get the WebAuthn challenge. - Uses
navigator.credentials.get()
to invoke the browser’s biometric prompt. - Sends the authentication response back to your backend (
app_biometrics_check_biometric_registration
) for verification. - Reloads the page on success or shows an alert on failure.
- Calls your Symfony backend (
- User Experience Enhancements:
- Includes Bootstrap for styling and Font Awesome for icons.
- A logout button is provided for quick exit.
login.html.twig
{% extends 'base.html.twig' %}
{% block title %}Log in!{% endblock %}
{% block login %}
<div class="container">
<!-- Outer Row -->
<div class="row justify-content-center">
<div class="col-xl-10 col-lg-12 col-md-9">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-6 d-none d-lg-block bg-login-image justify-content-center align-items-center h-100 m-auto">
<img src="{{ asset('assets/img/undraw_posting_photo.svg') }}" class="img-thumbnail img-fluid border-0" alt="login img">
</div>
<div class="col-lg-6">
<div class="p-5">
<div class="text-center">
<h1 class="h4 text-gray-900 mb-4">Welcome Back!</h1>
</div>
<form class="user" method="post">
<div class="form-group">
<input type="email" class="form-control form-control-user"
id="exampleInputEmail" name="_username" aria-describedby="emailHelp"
placeholder="Enter Email Address...">
</div>
<div class="form-group">
<input type="password" class="form-control form-control-user"
id="exampleInputPassword" name="_password" placeholder="Password">
</div>
<div class="form-group">
<div class="custom-control custom-checkbox small">
<input type="checkbox" class="custom-control-input" id="customCheck">
<label class="custom-control-label" for="customCheck">Remember
Me</label>
</div>
</div>
<input type="hidden" name="_csrf_token"
value="{{ csrf_token('authenticate') }}"
>
<button type="submit" class="btn btn-primary btn-user btn-block">
Login
</button>
<hr>
</form>
<hr>
<div class="text-center">
<a class="small" href="#">Forgot Password?</a>
</div>
<div class="text-center">
<a class="small" href="#">Create an Account!</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
setting/index.html.twig
{% extends 'base.html.twig' %}
{% block title %} Profile {% endblock %}
{% block body %}
<div class="card">
<div class="card-header">
<h4 class="card-title">Two-Factor authentication settings</h4>
</div>
<div class="card-body">
<form method="post" id="two-factor-auth-form">
<div class="form-group">
<label for="2fa">Enable two-factor authentication</label>
<select class="form-control" id="2fa" name="2fa">
<option value="0" {% if not app.user.enable2fa %} selected {% endif %}>No</option>
<option value="1" {% if app.user.enable2fa %} selected {% endif %}>Yes</option>
</select>
<input type="hidden" name="_token" value="{{ csrf_token('manage_two_factor_auth') }}">
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
<hr>
<div class="card-body">
<form method="post" id="bio-metrics-auth-form">
<div class="form-group">
<label for="2fa">Enable Biometric authentication</label>
<select class="form-control" id="2fa" name="bio_metrics">
<option value="0" {% if not app.user.enableBioMetricsFor2fa %} selected {% endif %}>No</option>
<option value="1" {% if app.user.enableBioMetricsFor2fa %} selected {% endif %}>Yes</option>
</select>
<input type="hidden" name="_token" value="{{ csrf_token('bio_metrics_auth') }}">
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
<script src="{{ asset('assets/js/common.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('two-factor-auth-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
fetch('{{ path('settings_manage_two_factor_auth') }}', {
method: 'POST',
body: formData
}).then((res) => {
return res.json();
}).then(response => {
if (response.status) {
Swal.fire({
icon: 'success',
title: 'Success',
text: 'Updated Authentication Settings'
}).then(() => {
window.location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: response.errorMessage
});
}
});
});
document.getElementById('bio-metrics-auth-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
if (formData.get('bio_metrics') === '1') {
if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
window.alert('Browser not supported.');
return;
}
let biometricsDataCreateArgsURL = "{{ path('bio_metrics_create_args') }}";
fetch(biometricsDataCreateArgsURL, {
method: 'POST',
cache: 'no-cache'
}).then((response) => {
return response.json();
}).then((res) => {
if (!res.status) {
throw new Error(res);
}
let createdArgs = res.createdArgs;
return recursiveBase64StrToArrayBuffer(createdArgs);
}).then((createCredentialArgs) => {
return navigator.credentials.create(createCredentialArgs);
}).then((cred) => {
return {
clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null
};
}).then(JSON.stringify).then(function(AuthenticatorAttestationResponse) {
let biometricsDataProcessCreateURL = "{{ path('bio_metrics_process_create') }}";
fetch(biometricsDataProcessCreateURL, {
method: 'POST',
body: AuthenticatorAttestationResponse,
cache: 'no-cache'
}).then((res) => {
return res.json();
}).then((response) => {
if (response.success) {
manageBioMetrics(formData);
}
});
})
} else {
manageBioMetrics(formData);
}
});
function manageBioMetrics(formData)
{
fetch('{{ path('settings_manage_bio_metrics') }}', {
method: 'POST',
body: formData
}).then((res) => {
return res.json();
}).then(response => {
if (response.status) {
Swal.fire({
icon: 'success',
title: 'Success',
text: 'Updated Authentication Settings'
}).then(() => {
window.location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: response.errorMessage
});
}
});
}
});
</script>
{% endblock %}
This page allows logged-in users to enable or disable their preferred two-factor authentication methods.
What It Includes:
- Two Forms:
- Two-Factor Authentication (Email-based):
A select box to enable/disable TOTP 2FA. Submits via AJAX tosettings_manage_two_factor_auth
. - Biometric Authentication:
A separate form that allows users to enable/disable biometric login. If enabled, it triggers the full WebAuthn registration flow in the browser.
- Two-Factor Authentication (Email-based):
- JavaScript (AJAX + WebAuthn):
- Uses
fetch()
to post form data asynchronously. - If biometric 2FA is enabled, the script:
- Fetches WebAuthn creation options from the backend.
- Uses
navigator.credentials.create()
to register the biometric device. - Sends the response to
bio_metrics_process_create
to store it in the backend.
- Displays success or error messages using SweetAlert.
- Uses
email/totp_email.html.twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your OTP Code</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f6f9;
margin: 0;
padding: 0;
}
.container {
max-width: 480px;
margin: 40px auto;
background-color: #ffffff;
border-radius: 8px;
padding: 32px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.header {
text-align: center;
color: #333333;
font-size: 20px;
margin-bottom: 24px;
}
.otp-box {
background-color: #f0f4ff;
border: 2px dashed #4a90e2;
color: #4a90e2;
font-size: 32px;
text-align: center;
padding: 16px;
letter-spacing: 4px;
font-weight: bold;
border-radius: 6px;
margin: 16px 0;
}
.footer {
font-size: 14px;
color: #888888;
text-align: center;
margin-top: 24px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
Hello {{ user.name|default(user.email) }},
</div>
<p style="font-size: 16px; color: #333333;">
Use the code below to complete your verification. This code is valid for <strong>{{ validForMinutes }}</strong> minutes.
</p>
<div class="otp-box">
{{ otp }}
</div>
<p style="font-size: 15px; color: #555;">
If you didn’t request this code, you can safely ignore this email.
</p>
<div class="footer">
— The Biometric App Team
</div>
</div>
</body>
</html>
common.js
function recursiveBase64StrToArrayBuffer(obj)
{
const prefix = "=?BINARY?B?";
const suffix = "?=";
if (typeof obj === "string" && obj.startsWith(prefix) && obj.endsWith(suffix)) {
// Extract Base64 string
let base64Str = obj.slice(prefix.length, -suffix.length);
// Decode Base64 into binary string
let binaryString = atob(base64Str);
// Convert binary string into ArrayBuffer
let bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer; // Return as ArrayBuffer
}
if (Array.isArray(obj)) {
return obj.map(item => recursiveBase64StrToArrayBuffer(item)); // Process arrays recursively
}
if (obj !== null && typeof obj === "object") {
let newObj = {}; // Create a new object to avoid mutation issues
Object.entries(obj).forEach(([key, value]) => {
newObj[key] = recursiveBase64StrToArrayBuffer(value);
});
return newObj;
}
return obj;
}
/**
* Convert a ArrayBuffer to Base64
* @param {ArrayBuffer} buffer
* @returns {String}
*/
function arrayBufferToBase64(buffer)
{
return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
}
function vibrateIfPossible()
{
const canVibrate = "vibrate" in navigator;
if (canVibrate) {
window.navigator.vibrate(100);
}
}
In this tutorial, we explored how to implement biometric authentication as a two-factor authentication (2FA) method in a Symfony application. We combined the power of Symfony’s event system, WebAuthn API, and traditional email-based OTP to provide users with flexible and secure authentication options.
We covered:
- Setting up the project and installing necessary packages
- Creating essential controllers, helpers, and Twig templates
- Handling both TOTP and biometric (WebAuthn) flows
- Giving users the ability to manage their 2FA settings dynamically
This approach not only strengthens account security but also improves user experience by offering modern authentication methods like fingerprint or Face ID.
The full codebase is available on GitHub:
👉 View on GitHub
If you have questions or want a deep dive into specific parts, feel free to comment below—I’d be happy to help or even write a follow-up post!