Create One-Time Passwords in PHP with Symfony and Twilio's Verify API

September 22, 2021
Written by
Oluyemi Olususi
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Create One-Time Passwords in PHP with Symfony and Twilio's Verify API

As security threats continue to grow and their impacts become ever-more significant, Two-factor Authentication (2FA) is progressively becoming a de facto security standard. As an extra layer of security to the traditional username/email and password combination normally provided by the user(s) of an application, Two-factor authentication works and can be implemented by:

  • Generating and sending a numeric code to the user's mobile device either via SMS, email, or phone call. This is popularly called One-Time Password (OTP) as it is a short-live password that can only be used once during authentication.
  • Using an authenticator app to provide a constantly rotating set of codes that can be used for authentication when needed.
  • Using push authentication where a user responds to a device push notification to either approve or reject an in-application event.

In this article, I will show you how to generate an OTP and send it to the user via an SMS in a Symfony application using the Twilio's Verify API. Twilio provides a robust infrastructure to simplify the process of generation, transmission (via various media), and verification of tokens thereby relieving us of the burden of having to implement it in our application.

Prerequisites

To get the most out of this tutorial, you will need the following:

Create the base Symfony application

To begin, create a new Symfony project named sms_otp_generator and move into it, by running the commands below.

symfony new sms_otp_generator
cd sms_otp_generator

Install the required dependencies

For this project, we will use the following packages:

  1. Doctrine: The Doctrine ORM will help with managing the application's database.
  2. DoctrineFixturesBundle: This will help us load a user into the database.
  3. Maker: This will help us create controllers, entities, and the like, as well as to set up authentication.
  4. Symfony Security: This will help with authentication and access control in the application.
  5. Twig: Twig is a modern template engine for PHP. We will use it to render the views for our application.
  6. Twilio: The Twilio PHP SDK makes it easy to interact with Twilio APIs from your PHP application.

To install them all, run the two commands below.

composer require doctrine security twilio/sdk twig
composer require --dev maker orm-fixtures

Update the application's configuration

Next, create a .env.local file from the .env file, which Symfony generated during the creation of the project, by running the command below.

cp .env .env.local

This file is ignored by Git as it matches an existing pattern in .gitignore (which Symfony generated). One of the reasons for this file is the accepted best practice of storing your credentials outside of code to keep them safe.

Next, update the DATABASE_URL parameter in .env.local so that the app uses an SQLite database instead of the PostgreSQL default. To do that, comment out the existing DATABASE_URL entry, and uncomment the SQLite option so that it matches the example below.

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Now create the database using the following command:

symfony console doctrine:database:create

NOTE: The database will be created in the var directory in the project's root directory and be named data.db.

With those changes made, to be sure everything is working properly, start the application by running the command below.

symfony serve

By default, Symfony projects listen on port 8000, so navigating to http://localhost:8000/ will show the default Symfony welcome page, similar to the image below.

The default Symfony home page

After confirming that the application works, stop it by pressing ctrl + c.

Create the User entity

Next, let's create an entity to store user-related data, such as a username, full name, and phone number. Create it by running the command below.

symfony console make:user

The CLI will ask some questions to help with setting up the user. Answer them as shown below.

The name of the security user class (e.g. User) [User]:
 > User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > username

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > yes

Once you are done, you will see the following output:

created: src/Entity/User.php
 created: src/Repository/UserRepository.php
 updated: src/Entity/User.php
 updated: config/packages/security.yaml

           
  Success! 
           

 Next Steps:
   - Review your new App\Entity\User class.
   - Use make:entity to add more fields to your User entity and then run make:migration.
   - Create a way to authenticate! See <https://symfony.com/doc/current/security.html>

We also need a field for the user's phone number which we will use to send the OTP. To do that, open src/Entity/User.php and add the code below.

/**
 * @ORM\Column(length=15)
 */
private $phoneNumber;

public function __construct(string $username, string $phoneNumber)
{
    $sanitizedPhoneNumber = $this->sanitizePhoneNumber($phoneNumber);
    $this->validatePhoneNumber($sanitizedPhoneNumber);
    $this->username = $username;
    $this->phoneNumber = $sanitizedPhoneNumber;
}

private function sanitizePhoneNumber(string $phoneNumber)
{
    // remove + sign if provided in string
    return str_replace('+', '', $phoneNumber);
}

private function validatePhoneNumber(string $phoneNumber)
{
    if (!preg_match('/[1-9]\d{1,14}$/', $phoneNumber)) {
        throw new \InvalidArgumentException(
            'Please provide phone number in E164 format without the \'+\' symbol'
        );
    }
}

public function getPhoneNumber(): string
{
    return "+$this->phoneNumber";
}

public function setPhoneNumber(string $phoneNumber): void 
{
    $sanitizedPhoneNumber = $this->sanitizePhoneNumber($phoneNumber);
    $this->validatePhoneNumber($sanitizedPhoneNumber);
    $this->phoneNumber = $sanitizedPhoneNumber;
}

First, we add a field for the phone number. To keep things simple, the phone number is stored in the E.164 format (without the + sign). Next, we add a constructor which allows us to create a new user by providing a username and phone number. The last thing we did was to add an accessor and mutator function for the phone number. We also defined a validation mechanism to ensure that the provided phone number matches the regex for our expected phone number format.


With this in place, we can run the necessary migrations to update the database schema by running the following commands.

symfony console make:migration
symfony console doctrine:migrations:migrate

Add login functionality

The first layer of authentication will be a traditional login form where the user provides a username and password. We can implement this with the Maker bundle by running the following command.

symfony console make:auth

Answer the questions asked by the CLI as shown below.

What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > LoginAuthenticator

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > SecurityController

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 > yes

With the questions answered, update the App\Security\LoginAuthenticator's onAuthenticationSuccess() method to match the following.

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('home'));
}

This function is called when the user provides a valid email address and password combination. When called, it checks if the user was redirected to the login page from another route. If so, the user is returned to that route. If not, they are redirected to the home page.

Next, let's update the access control configuration to ensure that the user is logged in before accessing any page (apart from the login page). To do that, update the access_control key in config/packages/security.yaml to match the following.

access_control:
     - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
     - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

We also want the user to be redirected to the login page after logging out. To do this modify the main configuration, under firewalls, to match the following.

main:
    lazy: true
    provider: app_user_provider
    custom_authenticator: App\Security\LoginAuthenticator
    logout:
         path: app_logout
         target: app_login

The last thing we need to do, with regards to authentication, is redirect the user to the home route if they are already logged in. To do this, update the login function in src/Controller/SecurityController.php to match the following code.

public function login(AuthenticationUtils $authenticationUtils): Response 
{
    if ($this->getUser()) {
        return $this->redirectToRoute('home');
    }

    // 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
    ]);
}

Implementing the home route

At the moment we don't have a route named "home", so on successful authentication the user will be greeted with a RouteNotFoundException. We don't want that. So let's create a controller to handle requests to that route. Create a new controller using the following command.

symfony console make:controller HomeController

This will create two new files:

  • A controller located in src/Controller/HomeController
  • A view page in templates/home/index.html.twig

Load a default user into the database

Let's create a fixture that will add a user to the database, by running the following command to create a fixture.

symfony console make:fixture UserFixtures

Edit src/DataFixtures/UserFixtures.php to match the following.

<?php

namespace App\DataFixtures;

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

class UserFixtures extends Fixture 
{
    private UserPasswordHasherInterface $hasher;

    public function __construct(UserPasswordHasherInterface $hasher) 
    {
        $this->hasher = $hasher;
    }

    public function load(ObjectManager $manager) 
    {
        $user = new User('yemiwebby', '23412345678');
        $hashedPassword = $this->hasher->hashPassword($user, 'Secret1234!');
        $user->setPassword($hashedPassword);

        $manager->persist($user);
        $manager->flush();
    }
}

Please use a valid phone number to ensure you receive the OTP when it is sent by Twilio. Otherwise, if you check in the Network tab of your browser's Developer Tools, you'll see that the request to send the OTP failed with: "[HTTP 400] Unable to create record: Invalid parameter To:<your phone number>"

With the fixture created, load it by running the following command.

symfony console doctrine:fixtures:load -n

Add the bootstrap theme

Let's use Bootstrap to style the views in our application. Open config/packages/twig.yaml and update it to match the following.

twig:
    default_path: '%kernel.project_dir%/templates'
    form_themes: ['bootstrap_5_layout.html.twig']

when@test:
    twig:
        strict_variables: true

Next, add the Bootstrap CDN import in the base Twig template, by editing templates/base.html.twig to match the following.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Welcome!{% endblock %}</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
          rel="stylesheet"
          integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We"
          crossorigin="anonymous">
    {% block stylesheets %}
    {% endblock %}

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj"
            crossorigin="anonymous">
    </script>
    {% block javascripts %}
    {% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

Finally, update the templates/security/login.html.twig file to match the following.

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

{% block title %}Log in!{% endblock %}

{% block body %}
    <div class="container py-3">
        <form method="post">
            {% if error %}
                <div class="alert alert-danger">
                    {{ error.messageKey|trans(error.messageData, 'security') }}
                </div>
            {% endif %}

            <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>

            <div class="mb-3">
                <label for="inputUsername">Username</label>
                <input type="text" 
                       value="{{ last_username }}" 
                       name="username" id="inputUsername"
                       class="form-control"
                       autocomplete="username" 
                       required 
                       autofocus>
            </div>
            <div class="mb-3">
                <label for="inputPassword">Password</label>
                <input type="password" 
                       name="password" 
                       id="inputPassword" 
                       class="form-control"
                       autocomplete="current-password" 
                       required>
            </div>

            <input type="hidden" 
                       name="_csrf_token"
                       value="{{ csrf_token('authenticate') }}">

            <button class="btn btn-lg btn-primary" type="submit">
                Sign in
            </button>
        </form>
    </div>
{% endblock %}

With those changes made, run the application using the following command.

symfony serve

Then, navigate to the login page (https://127.0.0.1:8000/login) and provide the username and password for the user previously loaded into the database. You will be redirected to the home page which currently looks like the screenshot below.

The default page for HomeController

Fix the home page

At the moment, we're seeing the default home page which the Maker bundle created for us earlier. Let's replace the "friendly message" being displayed with a button group which allows the user to either generate an OTP or to log out. We'll also replace the name "HomeController" with the username of the logged-in user.

Before we do that, let's create a stylesheet for the home page and add some styling for the content we will be adding. In the public folder, create a new folder called css. In the public/css folder, using your text editor or IDE, create a new file called home.css and add the following to it.

.main-wrapper {
    height: 100vh;
}

.content {
    align-items: center;
    display: flex;
    flex-direction: column;
    height: 100%;
    justify-content: center;
}

Next, edit the code in templates/home/index.html.twig to match the following.

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

{% block stylesheets %}
  <link rel="stylesheet" href="css/home.css">
{% endblock %}

{% block body %}
    <div class="main-wrapper">
        <div class="content">
            <h1>Hello {{ username }}! ✅</h1>
            <div class="btn-group">
                <button class="btn btn-outline-primary">Generate OTP </button>
                <a href="{{ path('app_logout') }}" class="btn btn-outline-danger">Logout</a>
            </div>
        </div>
    </div>
{% endblock %}

Finally, modify the index function in src/Controller/HomeController.php to match the following.

/**
  * @Route("/home", name="home")
  */
public function index(): Response
{
    return $this->render('home/index.html.twig', [
        'username' => $this->getUser()->getUserIdentifier(),
    ]);
}

Reload the index page to see the updated home page as shown in the screenshot below.

The updated home page content

Clicking the "logout" button redirects the user to the login page as expected, however, we still have to implement the functionality for the Generate OTP button.

Set up Twilio

In order to connect our application with Twilio's verify API, so that our application can generate OTPs, we need three things: our Twilio Account SID and Auth Token, and a Verification Service ID.

Before we retrieve those, let's create environment variables to hold them by adding the following to your .env.local files.

TWILIO_ACCOUNT_SID="your_twilio_account_sid"
TWILIO_AUTH_TOKEN="your_twilio_auth_token"
TWILIO_VERIFICATION_SID="your_twilio_verification_sid"

Next, update the parameters key in config/services.yaml to match the following.

parameters:
    twilioSID: '%env(resolve:TWILIO_ACCOUNT_SID)%'
    twilioToken: '%env(resolve:TWILIO_AUTH_TOKEN)%'
    twilioVerificationSID: '%env(resolve:TWILIO_VERIFICATION_SID)%'

Next, from the Twilio Dashboard retrieve your "ACCOUNT SID", and "AUTH TOKEN". Replace each of the respective placeholder values in .env.local with these values.

Note: As a security precaution, your AUTH TOKEN will not be shown on screen. Click on the Copy icon to copy it.

Create a new Verification Service

To get a verification service ID, we need to create a verification service. Head to the Twilio Verify Console and click the "Create new service" button.

Specify a friendly name for the Verification Service

Set the friendly name to "sms_otp_generator" and click Create. You will be taken to the "General Settings" page which will show you the service ID for the verification service. Copy the Service SID value and use it to replace the final placeholder in .env.local.

Create a helper class for OTP generation and verification

In the src/Security folder, using your editor or IDE, create a new file called OTPService.php and update its content to match the following.

<?php

namespace App\Security;

use Twilio\Rest\Client;
use Twilio\Rest\Verify\V2\ServiceContext;

class OTPService 
{
    private ServiceContext $twilio;

    public function __construct(
        string $twilioSID,
        string $twilioToken,
        string $twilioVerificationSID
    ) {
        $client = new Client($twilioSID, $twilioToken);
        $this->twilio = $client->verify->v2->services($twilioVerificationSID);
    }

    public function generateOTP(string $phoneNumber): void 
    {
        $this->twilio->verifications->create($phoneNumber, 'sms');
    }

    public function isValidOTP(string $otp, string $phoneNumber): bool 
    {
        $verificationResponse = $this->twilio->verificationChecks->create($otp, [
            'to' => $phoneNumber
        ]);

        return $verificationResponse->status === 'approved';
    }
}

In this class, we define a constructor which takes the Account SID, Auth Token, and Verification Service ID as arguments and uses them to create a ServiceContext object, which will generate and verify OTPs.

We then declare a generateOTP function which takes a phone number in E.164 format and uses the ServiceContext to send a verification request to Twilio Verify API. It passes the phone number and specifies "sms" as the medium to send the OTP.

The last function we declare, isValidOTP, takes an OTP and phone number as arguments. Using these, it makes a verification check request to the Twilio Verify API, and returns a boolean based on whether or not the status in the response matches the expected status:  approved.

With our service in place, we can wire the constructor arguments together in config/services.yaml by adding the following to the services configuration.

App\Security\OTPService:
        arguments:
            $twilioSID : '%twilioSID%'
            $twilioToken : '%twilioToken%'
            $twilioVerificationSID : '%twilioVerificationSID%'

Create the OTPController

Let's now create a controller to handle requests related to OTP management, in this case generation and verification. Stop the application from running by pressing ctrl + c and proceed to create a new controller (without a template file) using the following command.

symfony console make:controller OTPController --no-template

Open src/Controller/OTPController.php and edit it to match the following.

<?php

namespace App\Controller;

use App\Security\OTPService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/otp")
 */
class OTPController extends AbstractController
{
    private OTPService $otpService;

    public function __construct(OTPService $otpService) 
    {
        $this->otpService = $otpService;
    }

    /**
     * @Route("/generate", name="generateOTP", methods={"GET"})
     */
    public function generate(): Response 
    {
        $this->otpService->generateOTP($this->getUser()->getPhoneNumber());

        return $this->json([
            'message' => 'OTP sent to registered phone number!',
        ]);
    }

    /**
     * @Route("/verify", name="verifyOTP", methods={"POST"})
     */
    public function verify(Request $request): Response 
    {
        $otp = $request->toArray()['otp'];
        $phoneNumber = $this->getUser()->getPhoneNumber();

        if (!$this->otpService->isValidOTP($otp, $phoneNumber)) {
            return $this->json([
                'error' => 'Invalid OTP provided'
            ], Response::HTTP_BAD_REQUEST);
        }

        return $this->json([
            'message' => 'Success!!!',
        ]);
    }
}

In this controller, we injected the OTPService via the controller's constructor. Next, we declared a route to generate an OTP for the logged-in user. Because the user has to be logged in fully, we can use the getUser() function provided by the AbstractController to get the logged-in user's phone number. We pass this phone number to the OTPService and generate an OTP for the user.

The endpoint that verifies an OTP retrieves the OTP from the POST request and passes that along with the logged-in user's phone number to the OTPService. If the OTP is valid, we return a success response, otherwise we return an error message along with an HTTP 400 response.

Make the Generate OTP button work

With the backend set up to generate and verify OTPs, let's update the template for the home page to make the necessary requests.

We'll use SweetAlert to display the OTP input popup and make an AJAX request to verify the input content.

Update the code in templates/home/index.html.twig to match the following.

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

{% block stylesheets %}
    <link rel="stylesheet" href="css/home.css">
{% endblock %}

{% block body %}
    <div class="main-wrapper">
        <div class="content">
            <h1>Hello {{ username }}! ✅</h1>
            <div class="btn-group">
                <button
                        onclick="showOTPForm()"
                        class="btn btn-outline-primary"
                >
                    Generate OTP
                </button>
                <a
                        href="{{ path('app_logout') }}"
                        class="btn btn-outline-danger"
                >
                    Logout
                </a>
            </div>
        </div>
    </div>
{% endblock %}

{% block javascripts %}
    <script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
    <script>
        const generateOTP = () => {
            fetch("{{ path('generateOTP') }}", {
                method: 'GET'
            })
        };

        const showOTPForm = () => {
            generateOTP();
            Swal.fire({
                title: 'Enter your OTP',
                input: 'text',
                inputAttributes: {
                    autocapitalize: 'off'
                },
                showCancelButton: true,
                confirmButtonText: 'Verify',
                showLoaderOnConfirm: true,
                preConfirm: (otp) => {
                    return fetch("{{ path('verifyOTP') }}", {
                        method: 'POST',
                        body: JSON.stringify({otp})
                    })
                        .then(response => {
                            return new Promise(resolve => response.json()
                                .then(json => resolve({
                                    status: response.status,
                                    ok: response.ok,
                                    json
                                }))
                            )
                        })
                        .then(({ok, json}) => {
                            if (ok) {
                                Swal.fire({
                                    title: json.message,
                                });
                            } else {
                                Swal.showValidationMessage(json.error);
                            }
                        });
                },
                backdrop: true,
                allowOutsideClick: () => !Swal.isLoading()
            });
        }
    </script>
{% endblock %}

Run the application again using:

symfony serve

and click the “Generate OTP” button. You will see a pop-up asking for the OTP. If you provide the wrong OTP, you will see an error as shown in the screenshot below.

Pop up for OTP

If you provide the correct OTP, a success response will be returned as shown below.

Success page

 

That's how to generate a One-Time Password via SMS

We have successfully built a feature to generate an OTP via SMS. This is an especially handy feature to have in terms of accessibility as visually impaired users are not left out.

We also saw a key benefit of leveraging the Twilio API. Using the Twilio SDK, we were able to generate an OTP via SMS in one line of code. We also did not have to worry about managing OTPs as the Twilio API took care of that as well.

The entire codebase for this tutorial is available on GitHub. Feel free to explore further. Happy coding!

Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest to solve day-to-day problems encountered by users, he ventured into programming and has since directed his problem-solving skills at building software for both web and mobile.

A full-stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and content on several blogs on the internet. Being tech-savvy, his hobbies include trying out new programming languages and frameworks.