Build a Secure Password Reset System with Twilio Verify in CakePHP

April 29, 2025
Written by
Isijola Jeremiah
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Secure Password Reset System with Twilio Verify in CakePHP

Forgetting a password can be a frustrating experience for users and a security concern for developers. A secure password reset system ensures that only the rightful account owner can regain access. Specifically, instead of relying on emails, using phone numbers for password resets adds an extra layer of security and accessibility.

In this tutorial, we will build a secure password reset system in CakePHP using Twilio Verify backed by a MySQL database. You'll learn how to generate and send one-time verification codes to users' phone numbers, validate those codes, and securely update passwords. By the end of this guide, you'll have a robust password reset system that enhances both security and the user experience.

Prerequisites

To follow along with this tutorial, the following prerequisites are required:

  • PHP 8.3 (8.4 is not fully supported, currently)
  • Composer installed globally
  • Access to a MySQL database
  • Basic knowledge of or experience with CakePHP
  • A Twilio account (free or paid). Create a new account if you are new to Twilio
  • Your preferred text editor or IDE and web browser

Create a CakePHP project

To create a new CakePHP project using Composer, run the command below in your terminal.

composer create-project --prefer-dist cakephp/app:~5.0 secure_password

When you see the prompt " Set Folder Permissions? (Default to Y) [Y, n]?", respond with " Y" to proceed with the project installation.

After that, run the command below to navigate to the project's working directory and start the application development server.

cd secure_password bin/cake server

After the application server starts, open http://localhost:8765 in your browser to view the application's default welcome page, as shown in the screenshot below, to confirm that the base application is working.

Screenshot of CakePHP 5.1.4 Chiffon welcome page with system check details

Now, open the project code in your preferred code editor.

Set up the database

To connect your application to the MySQL database, open the project in your code editor. Then, navigate to the config folder and open the app_local.php file.

The database configuration is in the default subsection of the Datasource section. In this subsection, you will need to replace the values for the host, username, password, and database name with your database details.

Screenshot of a PHP configuration file defining database connection settings including host, username, and password.

Next, log in to your MySQL database server and create a new database named users.

Then, create a database table named users using CakePHP's migration features. First, run the command below to generate the migration file defining the table's properties.

bin/cake bake migration users

After that, go to the config/Migrations folder, open the migration file that ends with _Users.php, and add the following code to the change() function.

$table = $this->table('users');
$table->addColumn(
    'name', 
    'string', 
    [
        'limit' => 255,
        'null' => true,
    ]
);
$table->addColumn(
    'phone', 
    'string', 
    [
        'limit' => 15,
        'null' => false,
    ]
)->addIndex(
    ['phone'],
    ['unique' => true]
);
$table->addColumn(
    'email',
    'string',
    [
        'limit' => 255,
        'null' => true,
    ]
);
$table->addColumn(
    'password',
    'string',
    [
        'limit' => 255,
        'null' => true,
    ]
);
$table->create();

To complete the database migration, run the command provided below.

bin/cake migrations migrate

Install Twilio's PHP Helper Library

To simplify integrating Twilio's Verify API in your application, install Twilio's PHP Helper Library using the command below.

composer require twilio/sdk

Store your Twilio credentials as environment variables

To connect to Twilio using the PHP Helper Library, you will need your Twilio Account SID and Auth Token. We'll store them as environment variables in a .env file to keep them out of the code, making them easier to manage. CakePHP does not support .env files out of the box, so you will need to configure the application to do so.

To set up the .env file, start by creating a copy of the config/.env.example file and renaming it to .env. You can do this by running the command below:

cp config/.env.example config/.env

Now, for the CakePHP application to load environment variables into the application logic, you need to enable php-d otenv in the application configuration. To do that, go to the config folder, open the bootstrap.php file, and uncomment the following code.

if (!env('APP_NAME') && file_exists(CONFIG . '.env')) {     
    $dotenv = new \josegonzalez\Dotenv\Loader([CONFIG . '.env']);     
    $dotenv->parse()         
        ->putenv()         
        ->toEnv()         
        ->toServer(); 
}

Next, open the .env file and add the following environment variables to the end of the file.

TWILIO_ACCOUNT_SID="<twilio_account_sid>"
TWILIO_AUTH_TOKEN="<twilio_auth_token>"
TWILIO_VERIFICATION_SERVICE="<twilio_verification_service_id>"

Now, you will need your Twilio Account SID and Auth Token. You can find these by logging into your Twilio Console dashboard. They are located in the Account Info panel, as illustrated in the screenshot below.

Account information section displaying SID and partially hidden Auth Token with a security notice.

Substitute the first two placeholders in .env with your Twilio Account SID and Auth Token.

Set up a Verification Service

Next, you need to set up a Twilio Verification Service. This service provides common configurations for creating and verifying one-time passwords (OTPs). To do this, go to the Twilio Console and navigate to Explore Products > User Authentication & Identity > Verify.

Twilio interface showing options to set up verification channels for a Password Reset.

Click on "Create new" and complete the form that appears. Enter a "Friendly name" for the service, and check the box labelled "Authorize the use of friendly name". Under the Verification channels section, enable SMS. After that, click "Continue," and then click "Continue" again in the next prompt.

Interface for configuring password reset with options for SMS, WhatsApp, Email, Voice, Push, and TOTP.

Now, you will see the Service settings page for your new service. Copy the Service SID and paste it into .env in place of <twilio_verification_service_id>.

Create a database entity and model

To generate the application model and entity files, run the command below.

bin/cake bake model users

The command will create a model file named UsersTable.php in the /src/Model/Table folder and an entity file named User.php in the /src/Model/Entity folder.

Create the controller

Next, let's create the application's controller. Execute the command below to generate a controller file named UsersController.php within the src/Controller directory.

bin/cake bake controller users --no-actions

Next, add the application logic to the controller by opening the UsersController.php file and replacing the existing code with the following.

<?php


declare(strict_types=1);

namespace App\Controller;

use Cake\Http\Response;
use Cake\Log\Log;
use Cake\ORM\TableRegistry;
use Exception;
use Twilio\Exceptions\RestException;
use Twilio\Rest\Client;

class UsersController extends AppController
{
    public function register(): ?Response
    {
        $usersTable = TableRegistry::getTableLocator()->get('Users');
        $user = $usersTable->newEmptyEntity();

        if ($this->request->is('post')) {
            $user = $usersTable->patchEntity($user, $this->request->getData());

            if ($user->getErrors()) {
                $this->Flash->error(__('Please correct the errors in the form.'));
            } elseif ($usersTable->save($user)) {
                $this->Flash->success(__('Registration successful.'));
                return $this->redirect(['action' => 'reset']);
            } else {
                $this->Flash->error(__('Unable to register the user. Please try again.'));
            }
        }

        $this->set(compact('user'));
    }

    public function reset(): ?Response
    {
        $usersTable = TableRegistry::getTableLocator()->get('Users');

        if ($this->request->is('post')) {
            $phone = $this->request->getData('phone');
            $user = $usersTable->find('all', ['conditions' => ['phone' => $phone]])->first();

            if ($user) {
                $session = $this->request->getSession();
                $session->write('Reset.phone', $phone);



                $serviceSid = env('TWILIO_VERIFICATION_SERVICE');

                $session->write('Reset.serviceSid', $serviceSid);

                if ($this->_sendResetSMS($phone, $serviceSid)) {
                    $this->Flash->success(__('A password reset token has been sent to your phone.'));
                    return $this->redirect(['action' => 'verifyOtp']);
                } else {
                    $this->Flash->error(__('Unable to send the reset SMS. Please try again.'));
                }
            } else {
                $this->Flash->error(__('Phone number not found.'));
            }
        }

        $this->set('title', 'Password Reset');
    }

    private function _sendResetSMS(string $phone, string $serviceSid): bool
    {

        try {
            $phone = $this->formatPhoneNumber($phone);

            $twilio = new Client( 
                env('TWILIO_ACCOUNT_SID'), 
                env('TWILIO_AUTH_TOKEN') 
            );

            $twilio->verify->v2->services($serviceSid)
                ->verifications
                ->create($phone, 'sms');

            return true;
        } catch (\Twilio\Exceptions\RestException $e) {
            \Cake\Log\Log::error('Twilio error: ' . $e->getStatusCode() . ' - ' . $e->getMessage());
            return false;
        } catch (\Exception $e) {
            \Cake\Log\Log::error('Unexpected error: ' . $e->getMessage());
            return false;
        }
    }

    private function formatPhoneNumber(string $phone): string
    {
        if (!str_starts_with($phone, '+')) {
            $phone = '+1' . ltrim($phone, '0');
        }
        return $phone;
    }

    public function verifyOtp(): ?Response
    {
        $session = $this->request->getSession();
        $phone = $session->read('Reset.phone');
        $sid = $session->read('Reset.serviceSid');

        if (!$phone) {
            return $this->redirect(['action' => 'reset']);
        }

        if ($this->request->is('post')) {
            $otp = $this->request->getData('otp');

            try {
                $twilio = new Client( 
                    env('TWILIO_ACCOUNT_SID'), 
                    env('TWILIO_AUTH_TOKEN') 
                );
                $verificationCheck = $twilio->verify->v2->services($sid)
                    ->verificationChecks
                    ->create(["to" => $phone, "code" => $otp]);

                if ($verificationCheck->status === 'approved') {
                    $session->delete('Reset.serviceSid');
                    $this->Flash->success(
                        __('OTP verified successfully. You can now reset your password.'
                    ));
                    return $this->redirect(['action' => 'resetpassword']);
                } else {
                    $this->Flash->error(__('Invalid or expired OTP. Please try again.'));
                }
            } catch (\Exception $e) {
                \Cake\Log\Log::error('Twilio verification error: ' . $e->getMessage());
                $this->Flash->error(__('An error occurred while verifying the OTP. Please try again later.'));
            }
        }

        $this->set(compact('phone', 'sid'));
    }

    public function resetpassword(): ?Response
    {
        $usersTable = TableRegistry::getTableLocator()->get('Users');
        $session = $this->request->getSession();
        $phone = $session->read('Reset.phone');

        if (!$phone) {
            return $this->redirect(['action' => 'reset']);
        }

        if ($this->request->is('post') && $this->request->getData('password')) {
            $newPassword = $this->request->getData('password');
            $user = $usersTable->find('all', [
                'conditions' => ['phone' => $phone]
            ])->first();

            if ($user) {
                $user->password = $newPassword;

                if ($usersTable->save($user)) {
                    $this->Flash->success(__('Your password has been successfully reset.'));
                    return $this->redirect(['action' => 'reset']);
                } else {
                    $this->Flash->error(__('Unable to reset your password. Please try again.'));
                }
            } else {
                $this->Flash->error(__('Invalid or expired reset token.'));
            }
        }

        $this->set('title', 'Reset Password');
    }
}

Here is a breakdown of the above code:

  • The register()method validates the registration form and saves the user’s record. If successful, the user is redirected to the reset page. Otherwise, an error message is displayed.

  • The reset() method checks if the provided phone number exists in the database. If found, an OTP is sent via Twilio, and the user is redirected to the verification page. Otherwise, an error message is displayed.

  • The verifyOtp() method verifies the OTP entered by the user. If valid, the session is cleared, and the user is redirected to reset their password. Otherwise, an error message is displayed.

  • The resetpassword() method updates the user’s password. If successful, the user is redirected to the login page. Otherwise, an error message is displayed.

Create the UI template files

For each method in the UsersController you need to create a corresponding template that handles the page's content. To do that, navigate to the templates folder and create a new folder named Users. Inside the Users folder, create the following files:

  • register.php

  • reset.php

  • resetpassword.php

  • verify_otp.php

Next, add the following code to the register.php file:

<h1>Register</h1>
<?= $this->Form->create(null, ['url' => ['action' => 'register']]) ?>
<?= $this->Form->control('name', ['label' => 'Full Name']) ?>
<?= $this->Form->control('phone', ['label' => 'Phone Number', 'type' => 'tel']) ?>
<?= $this->Form->control('email', ['label' => 'Email Address']) ?>
<?= $this->Form->control('password', ['label' => 'Password', 'type' => 'password']) ?>
<?= $this->Form->button(__('Register')) ?>
<?= $this->Form->end() ?>

Then, add the following code to the reset.php file:

<h1><?= __('Reset Password') ?></h1>

<?= $this->Flash->render() ?>

<?= $this->Form->create(null, ['url' => ['action' => 'reset']]) ?>
<fieldset>
    <legend><?= __('Enter your phone number to reset your password') ?></legend>
    <?= $this->Form->control('phone', ['label' => 'Phone Number', 'type' => 'tel']) ?>
    <div id="otp-section" style="display: none;">
        <?= $this->Form->control('otp', ['label' => 'OTP', 'type' => 'text', 'autocomplete' => 'off']) ?>
    </div>
</fieldset>
<?= $this->Form->button(__('Verify Phone Number')) ?>
<?= $this->Form->end() ?>

Now, add the following code to the resetpassword.php file:

<h1>Reset Your Password</h1>

<?= $this->Form->create(null, ['url' => ['action' => 'resetpassword']]) ?>
<?= $this->Form->control('password', ['label' => 'New Password', 'type' => 'password']) ?>
<?= $this->Form->control('confirm_password', ['label' => 'Confirm Password', 'type' => 'password']) ?>
<?= $this->Form->button(__('Create Password')) ?>
<?= $this->Form->end() ?>

Finally, add the following code to the verify_otp.php file:

<h1><?= __('Verify Your Code') ?></h1>

<?= $this->Form->create(null, ['url' => ['action' => 'resetpassword']]) ?>
<?= $this->Form->control('verification_code', [
    'label' => 'Enter Verification Code',
    'type' => 'text',
    'required' => true,
    'placeholder' => '123456'
]) ?>
<?= $this->Form->button(__('Verify Code')) ?>
<?= $this->Form->end() ?>

Add the route configuration

Now, let’s add routes for "register", "reset", "resetpassword", and "verify_otp" to the application routes. To do that, navigate to the config folder and open the routes.php file. Inside the file, locate $routes->scope() and add the following code before $builder->fallbacks().

$builder->connect('/register', ['controller' => 'Users', 'action' => 'register']);
$builder->connect('/reset', ['controller' => 'Users', 'action' => 'reset']);
$builder->connect('/resetpassword', ['controller' => 'Users', 'action' => 'resetpassword']);
$builder->connect('/verifyotp', ['controller' => 'Users', 'action' => 'verify_otp']);

Test the application

Ensure the application is still running. Open http://localhost:8765/register in your browser to access the registration page and register a new user.

Screenshot of a CakePHP registration form with fields for full name, phone number, email address, and password.

After successfully registering your account, you will be redirected to the reset password page where you will input the phone number used in registering.

CakePHP password reset page with message 'Registration successful!' and phone number input field.

Below is an image of the OTP received:

Screenshot of an SMS from TWVerify with a verification code message and time stamps.

You will receive an OTP code via message on inputting your phone number. Enter the verification OTP received as shown in the screenshot below.

Web page to verify code with a password reset token message and a field to enter the verification code.

After the verification is complete, users can now input a new password successfully, as shown in the image below:

CakePHP password reset interface with fields for new password, confirm password, and create password button.

That's how to build a secure password reset system with Twilio Verify in CakePHP

In this tutorial, you learned how to build a secure password reset system in a CakePHP application using Twilio Verify. You implemented phone number verification for password reset requests, ensuring enhanced security by replacing traditional email-based resets with OTP authentication.

Isijola Jeremiah is a developer who specialises in enhancing user experience on both the backend and frontend. Contact him on LinkedIn.

Password icons created by kliwir art on Flaticon.