TypingDNA Verify 2FA Integration with Symfony


This tutorial will help you integrate Verify 2FA into an existing Symfony application in order to increase its security by providing a complementary identity verification mechanism (2FA).


In this tutorial we will create a new Symfony project, add basic user authentication to it, and then add Verify 2FA.

We will cover all the necessary steps to get this working on a local machine

To make the example as portable as possible we will be using Docker. For the purposes of this article we will be using an Ubuntu box, but the same should be replicable on any other platform.


There are a few preparatory steps that must be followed in order to end up with a working application:

  1. Create an account on TypingDNA.

  2. Once there, go to your Dashboard to find a screen similar to:

  3. verify-php-symfony-1
  4. Take a note of your Client Id and Client Secret

  5. Install docker on your development computer

  6. Create an account at ngrok (We will use this later to create a secure public URL to localhost which is mandatory for Verify 2FA)

  7. Download and install the ngrok binary

That’s it! You’re ready to start building your app.

Local environment

We will be using three different docker containers: php, nginx and database. Since all of them will be physically located in the same development machine, we will use docker-compose to easily interact with them.

Create a new directory to hold the project and inside of it create a docker-compose.yml file that looks like this:

version: '3.8'

        container_name: database
        image: mysql:8.0
        command: --default-authentication-plugin=mysql_native_password
            MYSQL_ROOT_PASSWORD: secret
            MYSQL_DATABASE: symfony
            MYSQL_USER: symfony
            MYSQL_PASSWORD: symfony
            - '4306:3306'
            - ./mysql:/var/lib/mysql

        container_name: php
            context: ./php
            - '9000:9000'
            - ./app:/var/www/symfony_docker
            - database
        container_name: nginx
        image: nginx:stable-alpine
                - '8080:80'
                - ./app:/var/www/symfony_docker
                - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
                - php
                - database

Now we need to put a couple of auxiliary directories together:

The file php/Dockerfile should look like this

FROM php:8-fpm

RUN apt update \
    && apt install -y zlib1g-dev g++ git libicu-dev zip libzip-dev zip \
    && docker-php-ext-install intl opcache pdo pdo_mysql \
    && pecl install apcu \
    && docker-php-ext-enable apcu \
    && docker-php-ext-configure zip \
    && docker-php-ext-install zip

WORKDIR /var/www/symfony_docker

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

RUN curl -sS https://get.symfony.com/cli/installer | bash
RUN mv /root/.symfony/bin/symfony /usr/local/bin/symfony
RUN git config --global user.email "YOUR_EMAIL" && \
    git config --global user.name "YOUR_NAME"

And the file nginx/default.conf like this:

server {

    listen 80;
    index index.php;
    server_name localhost;
    root /var/www/symfony_docker/public;
    error_log /var/log/nginx/project_error.log;
    access_log /var/log/nginx/project_access.log;

    location / {
        try_files $uri /index.php$is_args$args;

    location ~ ^/index\\.php(/|$) {
        fastcgi_pass php:9000;
        fastcgi_split_path_info ^(.+\\.php)(/.*)$;
        include fastcgi_params;

        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;

        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;


    location ~ \\.php$ {
        return 404;

With all this in place you’re ready to start your containers and start building your application in a local LEMP environment.

Use this command to get things going:

docker-compose up -d

Creating the project

The symfony binary is installed inside the php container, so let’s use it to create a new project:

docker-compose exec php symfony new . --full

Now, if you take a look at the contents of the app directory you’ll find the basic structure of your project:


If everything is in place you should be able to see this:


When pointing your browser to http://localhost:8080

Adding basic authentication

To add user authentication we need to perform the following steps:

Create the User class

Run the command docker-compose exec php symfony console make:user to have symfony create the User class.

Use the default answer for all the questions and you should end up with this screen:


Configure the database

Before you can execute any commands on the database, you need to configure the access information.

Start by copying the file app/.env to app/.env.local, then edit the latter to look like this:

# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#  * .env                contains default values for the environment variables needed by the app
#  * .env.local          uncommitted file with local overrides
#  * .env.$APP_ENV       committed environment-specific defaults
#  * .env.$APP_ENV.local uncommitted environment-specific overrides
# Real environment variables win over .env files.
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration

###> symfony/framework-bundle ###
###< symfony/framework-bundle ###

###> symfony/mailer ###
# MAILER_DSN=smtp://localhost
###< symfony/mailer ###

###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="postgresql://db_user:db_password@"
###< doctrine/doctrine-bundle ###

Update the database

This is a two step process. First we’ll create the appropriate migration through the command docker-compose exec php symfony console make:migration.

Then run the migration: docker-compose exec php symfony console doctrine:migrations:migrate -q.

Create the login form

Use the command docker-compose exec php symfony console make:auth to get this done efficiently.

Answer 1 when prompted What style of authentication do you want? [Empty authenticator]

Answer LoginAuthenticator when prompted The class name of the authenticator to create (e.g. AppCustomAuthenticator)

Then keep the defaults until you see a screen that looks like this:


Create the first user

For this we’re going to use Doctrine’s Fixtures feature, which must be installed.

Use the command docker-compose exec php composer require orm-fixtures --dev to have composer install and configure it for you.

Once done, issue the command docker-compose exec php symfony console make:fixtures UserFixtures to have symfony create all the necessary code for you to start putting contents into the database.

Edit the file app/src/DataFixtures/UserFixtures.php to make it look like this:


namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use App\Entity\User;

class UserFixtures extends Fixture
        private $passwordHasher;

        public function __construct(UserPasswordHasherInterface $passwordHasher)
            $this->passwordHasher = $passwordHasher;

    public function load(ObjectManager $manager)
        $user = new User();
            ->setPassword($this->passwordHasher->hashPassword($user, 'p4ssW0rd!'))


Load the fixtures into the database: docker-compose exec php symfony console doctrine:fixtures:load -q

Now point your browser to You should see something like this:


Enter the credentials you defined in your fixture class and hit “Sign In”. You’ll see something like this:


Don’t worry about it, it’s totally normal 😊.

The fact is, we didn’t tell symfony where to send the user once authenticated. Let’s fix that...

Creating the User Dashboard

In this section we’ll build a very basic dashboard to be used as a protected area.

Start by creating a new Controller:

docker-compose exec php symfony console make:controller

Name it DashboardController.

Edit the file app/src/Controller/DashboardController.php to look like this:


namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class DashboardController extends AbstractController
    #[Route('/dashboard', name: 'dashboard')]

    public function index(): Response
        return $this->render('dashboard/index.html.twig', [
            'user' => $this->getUser(),

This way, the template dashboard/index.html.twig will have access to the User object.

Finally, your template should look like this:

{% extends 'base.html.twig' %}

{% block title %}Hello!{% endblock %}

{% block body %}
<div class="form-container">
    <div class="card">
        <div class="card-header">
        <div class="card-body">
            <h5 class="card-title">{{ user.email }}</h5>                
            <a href="/logout" class="btn btn-primary">Logout</a>
{% endblock %}

Now you have the dashboard ready for user views.

There’s just one little detail missing: you need to connect the dashboard with the valid login. To do that you’ll need to edit the file app/src/Security/LoginAuthenticator.php and change the method onAuthenticationSuccess to look like this:

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
        return new RedirectResponse($targetPath);

    return new RedirectResponse($this->urlGenerator->generate('dashboard'));


Save the file, reload the page in the browser and you should see:


Great! Now you have a simple Symfony application that has a login and a protected area.

But, is it really protected? Look at what would happen if you logged out and tried to go straight to

We have to fix that.

Fortunately, doing so using Symfony is really simple: just edit the file app/config/packages/security.yaml and change the definitions under the key access_control to:

- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/logout, roles: ROLE_USER }
- { path: ^/dashboard, roles: ROLE_USER }       

And there you go. If someone tries to go directly to your dashboard they’ll be immediately redirected to the login page.

This security can be enough in some cases, but if you really want to make sure people are who they say they are, you better take some extra measures, like using a Two Factor Authentication mechanism.

For that, Verify 2FA is a great option, both for the user and, as you’ll see, also for you, as it is really simple to set up.


The first step in the integration process is to download the TypingDNA php client.

The way I did it (and I suggest you do it too) is to build a wrapper class around it so your application can be prepared for upcoming versions of the library (but you could use this one directly if you want).

Create a directory called TwoFactorAuthentication inside your app/src directory and place the file TypingDNAVerifyClient.php that you just downloaded in there.

Open the file and add namespace App\TwoFactorAuthentication; at the top.

Next, create a new file called TypingDNAWrapper.php in the same directory.

This file should contain the following:


namespace App\TwoFactorAuthentication;

class TypingDNAWrapper
    private TypingDNAVerifyClient $client;

    public function __construct(string $clientID, string $applicationID, string $secret)
        $this->client = new TypingDNAVerifyClient($clientID, $applicationID, $secret);

     * @param string $phoneNumber
     * @return array
    public function getDataAttributes(string $phoneNumber) : array
        return $this->getClient()->getDataAttributes(
                'phoneNumber' => $phoneNumber,
                'language' => 'en',
                'mode' => 'standard',

     * @param string $phoneNumber
     * @param string $otp
     * @return bool
    public function isValidOTP(string $phoneNumber, string $otp) : bool
        $response = $this->getClient()
                'phoneNumber' => $phoneNumber,
            ], $otp);

        return $response['success'];

    private function getClient() : TypingDNAVerifyClient
        return $this->client;

The idea of this wrapper is to simplify the interaction with the TypingDNA API by serving as a repository for the common information that must be exchanged in every call (The ClientId, ApplicationId and secret).


This information can be obtained from your Dashboard. Go to Verify 2FA Settings


Enter “Sf tutorial” in the field “Integration name”:


For the Domain field you’ll need a public domain name that points to your application.

Since you have an NginX server listening on port 8080 on your local machine (port 8080 being forwarded to port 80 on nginx docker container), you need a way to expose that to the Internet.

Here’s where ngrok comes into play.

Simply open a separate terminal and run the following:

ngrok http 8080

Then take the information of the https forwarding:


And paste it into the domain field of the integration definition:


Click “Create integration”.

This will result in an application id:


In order to have all this information injected into the service we’ll leverage Symfony’s configuration system.

Let’s start by adding a couple of entries to our .env.local file:


Service registration

The TypingDNAWrapper will be used as a new service. In order to have Symfony autowire it when we need it, we will have to create a special configuration for it inside the services.yaml file, since the constructor has some parameters that need to be defined.

Simply open the file app/config/services.yaml and add the following to the bottom:

    class: App\TwoFactorAuthentication\TypingDNAWrapper
        $clientID: '%env(TYPING_DNA_CLIENT_ID)%'
        $applicationID: '%env(TYPING_DNA_APP_ID)%'
        $secret: '%env(TYPING_DNA_CLIENT_SECRET)%'

This will have Symfony grab all the configuration from the .env.local file and use it to instantiate the service.

Updating the workflow

At this point you have everything you need to interact with TypingDNA! You just need to make a couple of adjustments to tie it all together and actually have the user confirm their identity.

Add the phone number to the User class

We are going to use the user’s phone number as the root of trust. To do that we will need to store it somewhere.

The best way to go about this is to add a new property to the User class. Start with the command docker-compose exec php symfony console make:entity User.

Since our User class is already defined, we’ll only be adding a new field to it, name it phoneNumber:


Then use the command docker-compose exec php symfony console make:migration to produce the code to update the database and finally run the migration with docker-compose exec php symfony console doctrine:migrations:migrate -q.

Now all you have to do is add the phone number to the user in the database.

In this case we’ll do it by updating the UserFixtures class.

The load method should look like this:

public function load(ObjectManager $manager)
        $user = new User();
            ->setPassword($this->passwordHasher->hashPassword($user, 'p4ssW0rd!'))


After that just reload the fixtures to the database using this command: docker-compose exec php symfony console doctrine:fixtures:load -q.

Prepare the integration

The next step is to add the 2FA to the login process. To do that you’ll have to change the class SecurityController, adding the following methods:

* @Route("/verify/", name="app_verify_otp")
public function verifyOTP(Request $request, TypingDNAWrapper $typingDNA) : Response
    $error = null;

    $user = $this->getUser();
    $phoneNumber = $user->getPhoneNumber();

    if ($otp = $request->get('otp')) {
        if ($typingDNA->isValidOTP($phoneNumber, $otp)) {
                ->setRoles(array_merge($user->getRoles(), ['TWO_FACTOR_PASSED']));

            return $this->redirectToRoute('dashboard');
        } else {

            return $this->redirectToRoute('2fa-failed');

    return $this->render(
            'error' => $error,
            'typingdna' => $typingDNA->getDataAttributes($phoneNumber)

* @return Response
* @Route (path="/2fa_failed", name="2fa-failed")
public function TwoFAFailed() : Response
    return $this->render(

Don’t forget to add the following at the top, otherwise the code won’t work.

use App\TwoFactorAuthentication\TypingDNAWrapper; 
use Symfony\Component\HttpFoundation\Request;                   

With these changes to the code we’re setting the stage for the application to trigger TypingDNA’s verification.

Upon successful verification the user will be granted a new role ('TWO_FACTOR_PASSED') which we will use as a means of confirmation that they are who they say they are.

Add templates

We already have the backend ready for interacting with TypingDNA’s API, it’s time to get the frontend in sync.

We’ll start by creating two new templates inside the security subdirectory. The first will be templates/security/verify.html.twig.

This template will serve as entrypoint to Verify 2FA’s workflow:

{% extends 'base.html.twig' %}

{% block title %}Verify OTP{% endblock %}

{% block body %}
        data-typingdna-client-id="{{ typingdna.clientId }}"
        data-typingdna-application-id="{{ typingdna.applicationId }}"
        data-typingdna-payload="{{ typingdna.payload }}"
        data-typingdna-callback-fn= "callbackFn"
        >Verify with Typingdna
{% endblock %}
{% block javascripts %}
    <script src="https://cdn.typingdna.com/verify/typingdna-verify.js"></script>
        function callbackFn(payload)
            window.location.href = "{{ url('app_verify_otp') }}?otp=".concat(payload['otp']);
{% endblock %}


This template includes TypingDNA’s JavaScript code which will handle the interaction with the API, starting with the click of the button.

It’s very important to respect the button class name as this is the marker used by the JS library to connect the event handler.

Before we go any further, let’s make a checkpoint.

Take your browser to https://NGROK_PROVIDED_URL/login and enter the details present in your database.

Next, change the URL to https://NGROK_PROVIDED_URL/verify. You should see a screen similar to:


If you click the button you should see a popup window similar to:


Follow the instructions in there and you’ll finally be redirected to the user Dashboard.

Great! Integration with TypingDNA is working, all that’s left is to close the circle by having the 2FA be mandatory in order to gain access to the protected area.

Update the redirection after login

The first step here is to have the user redirected to the verification screen upon successful login. To do that, open the file app/src/Security/LoginAuthenticator.php and change the method onAuthenticationSuccess to return a redirection to app_verify_otp instead of dashboard.

Update security definition

The final step is to update the firewall configuration to disallow access to the dashboard to users that don’t have the role 'TWO_FACTOR_PASSED'.

Open the file app/config/packages/security.yaml and change the contents of the key access_control to:

    - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
    - { path: ^/logout, roles: ROLE_USER }
    - { path: ^/dashboard, roles: TWO_FACTOR_PASSED }

Try it out. Log out and log in again to see what happens.


And you’re done! In this tutorial you learned how to integrate TypingDNA’s Verify to increase the security of a Symfony application.

A couple of tasks you might want to undertake in order to improve on this proof of concept application are:

  1. Move the javascript inclusion to WebPack

  2. Have the login method check the 2FA not only at /login but also upon attempts to directly access the protected area.

If you want to take a closer look at the sample code you can check out this repository.

For the production environment, in order to be able to send SMS OTPs you will need to integrate with Twillio in the Verify 2FA dashboard. First log in to your Twillio account and then click on the following button from the dashboard:


For more support, contact us at support@typingdna.com.

Tell your team there’s a better way to 2FA

Share this message across Slack, email, etc. We even jotted down some of the highlights to make it easier.

Check this out! 🚀 Found a cool way to 2FA our users: TypingDNA Verify 2FA. It authenticates people based on how they type — replacing authenticator apps and OTP codes. Awesome user experience! 🙌 Quick integration too (under 10 mins). And we can try it free with 1,000 users. What do you think? https://www.typingdna.com/verify