In recent years, user authentication in web applications has become a serious concern. For example, one-time passwords (OTP) are utilized to verify a user's identity; the most frequent method for sending OTPs is via SMS to the user's registered cellphone number.
In this article, however, you will learn how to deliver one-time passwords to users via voice calls using Twilio's Verify Api. In doing so, you will create an OTP system that can be used as an additional security layer for specific operations in your application.
Prerequisites
To follow this tutorial, you need the following:
- PHP 7.4 or higher with the PDO extension and PDO MySQL extension installed and enabled.
- Composer globally installed.
- MySQL and the MySQL command-line client (or an alternative database tool, such as DataGrip).
- A Twilio account. If you are new to Twilio, click here to create a free account.
Application Process
Here's how the application will work. Each time a user attempts to register or sign in, a voice call containing a one-time password will be sent to them. If the user attempts to register, a form to verify the user's phone number is filled out and, if verified, the user is redirected to a form where personal information is collected for storing in the application's database.
When a user attempts to sign in, the data entered in the registration form is compared to the data in the database. If a match is found, an OTP is sent to the user's phone number, and the user is then taken to a page where the OTP is validated. If the OTP is valid, the user is then redirected to the user dashboard.
Create the project directory
Create the project's root directory, called voice-otp, and navigate to it by running the commands below in the terminal.
mkdir voice-otp
cd voice-otp
Create the database
The next step is to create the user table, which will have the following fields:
- id
- fullName
- password
- tel (telephone)
In the terminal, run the command below to connect to the MySQL database using MySQL's command-line client, substituting your username for the placeholder ([username]
) and enter your password when prompted.
mysql -u [username] -p
Then, create a database called voice_otp
by running the command below.
CREATE DATABASE voice_otp;
After that, use the command below to verify that the database was created. If it were, you'd see it listed in the terminal output.
SHOW DATABASES;
With the database created, create the users table in the voice_otp
database, by running the commands below.
USE voice_otp;
CREATE TABLE user (
id int auto_increment,
fullName varchar(300) NOT NULL,
email varchar(300) NOT NULL,
tel varchar(20) NOT NULL,
password varchar(300) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
This generates a table with four columns: id
(which is auto-increment), fullName
, email
, tel
, and password
. Confirm that the table was created by running the SQL statement below.
DESCRIBE user;
With that done, exit MySQL's command-line client by pressing CTRL+D.
Set up the Twilio PHP Helper Library
To utilize Twilio's Verify API for user authentication, you need to install the Twilio PHP Helper Library. To do this, run the command below in your terminal.
composer require twilio/sdk
Then, in your editor or IDE, create a new file named config.php which will contain the app's configuration settings, and paste the code below into the new file.
<?php
$err_message = "";
require_once './vendor/autoload.php';
use Twilio\Rest\Client;
$sid = "ACCOUNT_SID";
$token = "AUTH_TOKEN";
$serviceId = "SERVICE_SID";
$twilio = new Client($sid, $token);
The code:
- Creates a variable for storing errors.
- Includes Composer's Autoloader file, vendor/autoload.php.
- Imports Twilio's PHP Helper Library.
- Creates three variables to store the required Twilio credentials and settings.
- Initializes a Twilio
Client
object, which is required to interact with Twilio's Verify API.
The Twilio Console dashboard contains your Twilio Auth Token and Account SID. Copy them and paste them in place of ACCOUNT_SID and AUTH_TOKEN, respectively in config.php.
The next step is to create a Twilio Verify service. First, go to the Twilio Verify Dashboard. Then, click the “Create Service Now” button and give the service a nice name in the "Create New Service" box, as seen in the screenshot below.
Create a new Twilio Verify service with a suitable name, such as "verify".
After you've entered a suitable name for the new service, click the blue Create button. The "General Settings" page will appear, which you can see in the screenshot below, where you can view the credentials for your new Twilio Verify service.
Copy the SERVICE SID value and paste it in place of SERVICE_SID
in config.php.
Then, you need to add your database connection details. To do this, first, add the following code to the bottom of config.php.
define('DBHOST', 'localhost');
define('DBUSER', 'root');
define('DBPASS', ' ');
define('DBNAME', 'voice_otp');
try {
$pdo = new PDO("mysql:host=".DBHOST.";dbname=".DBNAME, DBUSER, DBPASS);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch( PDOException $exception ) {
echo "Connection error :" . $exception->getMessage();
}
Then update the define
statements for DBHOST
, DBUSER
, DBPASS
, and DBNAME
to match your database's settings. The code uses the four variables and attempts to create a connection to the database.
PDO is used to connect to the database, because it supports several database vendors, including MySQL, MSSQL Server, PostgreSQL, and Oracle.
Next, paste the code below at the end of config.php.
function mask_no($number)
{
return substr($number, 0, 4) . '************' . substr($number, -4);
}
The function above hides the user's phone number by displaying only the first three and final four digits.
Create the signup and sign in page
Create a new file named signup.php in the project's root directory, and paste the code below into it.
<?php require_once('process.php');
$page = ((empty($_GET['p'])) ? 'step1' : strval($_GET['p']));
if (isset($_SESSION['init']) != true || is_array($_SESSION['init']) != true) {
$_SESSION['init'] = array();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Voice OTP</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Karla">
<link rel="stylesheet" href="assets/styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/17.0.15/css/intlTelInput.css">
<style>
.hide {
display: none;
}
#error-msg {
color: red;
}
</style>
</head>
<body style="font-family: Karla, sans-serif;">
<?php if ($page == 'step1') : ?>
<?php require_once('assets/content/step1.phtml'); ?>
<?php elseif ($page == 'step2' && isset($_SESSION['init']['status']) && !empty($_SESSION['init']['status'])) : ?>
<?php require_once('assets/content/step2.phtml'); ?>
<?php else : ?>
<?php require_once('assets/content/step1.phtml'); ?>
<?php endif; ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/17.0.15/js/intlTelInput.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/17.0.15/js/intlTelInput-jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script>
var input = document.querySelector("#phone"),
errorMsg = document.querySelector("#error-msg"),
validMsg = document.querySelector("#valid-msg");
// Error messages based on the code returned from getValidationError
var errorMap = ["Invalid number", "Invalid country code", "Too short", "Too long", "Invalid number"];
// Initialise plugin
var intl = window.intlTelInput(input, {
hiddenInput: "tel",
initialCountry: "auto",
autoHideDialCode: true,
preferredCountries: ['us', 'gb', 'ca', 'ng', 'cn'],
geoIpLookup: function(success, failure) {
$.get("https://ipinfo.io", function() {}, "jsonp").always(function(resp) {
var countryCode = (resp && resp.country) ? resp.country : "";
success(countryCode);
});
},
utilsScript: "https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/17.0.15/js/utils.js"
});
var reset = function() {
input.classList.remove("error");
errorMsg.innerHTML = "";
errorMsg.classList.add("hide");
validMsg.classList.add("hide");
};
// Validate on blur event
input.addEventListener('blur', function() {
reset();
if (input.value.trim()) {
if (intl.isValidNumber()) {
validMsg.classList.remove("hide");
} else {
input.classList.add("error");
var errorCode = intl.getValidationError();
errorMsg.innerHTML = errorMap[errorCode];
errorMsg.classList.remove("hide");
}
}
});
// Reset on keyup/change event
input.addEventListener('change', reset);
input.addEventListener('keyup', reset);
</script>
</body>
</html>
The code, above, generates a simplistic form for registering new users. It includes the file process.php, which contains the code for processing the sign-in & sign-up forms, connecting to Twilio's Verify API, and preventing users from registering without verifying their phone number.
Next, in the project's root directory, create a new directory named assets, and in that directory, create another directory called content, by running the following commands.
mkdir assets
mkdir assets\content
Then, create two new files, step1.phtml and step2.phtml, in the assets/content directory.
Add the code below to step1.phtml.
<div class="page">
<section class="register">
<div class="form-container">
<form method="POST">
<h2 class="text-center"><strong>Create</strong> an account.</h2>
<?php if ($err_message) : ?>
<div class="callout text-danger">
<p><?php echo $err_message; ?></p>
</div>
<?php endif; ?>
<div class="mb-3"><input class="form-control" type="tel" id="phone" name="phone" placeholder="Phone Number" value="<?php echo ((isset($_POST['tel'])) ? $_POST['tel'] : ''); ?>" required></div>
<span id="valid-msg" class="hide">✓ Valid</span>
<span id="error-msg" class="hide"></span>
<div class="mb-3"><button class="btn btn-primary d-block w-100" type="submit" name="telsign" required>Sign Up</button></div><a class="already" href="signin.php">Already have an account? Sign in here.</a>
</form>
</div>
</section>
</div>
step1.phtml contains code that collects the user’s phone number and sends it to process.php.
International telephone input is used to get all country codes.
Add the code below to step2.phtml.
<div class="page">
<section class="register">
<div class="form-container">
<form method="POST">
<h2 class="text-center"><strong>Create</strong> an account.</h2>
<?php if ($err_message) : ?>
<div class="callout text-danger">
<p><?php echo $err_message; ?></p>
</div>
<?php endif; ?>
<div class="mb-3"><input class="form-control" type="text" name="fullName" placeholder="Full Name" value="<?php if (isset($_POST['fullName'])) echo $_POST['fullName']; ?>" ></div>
<div class="mb-3"><input class="form-control" type="email" name="email" placeholder="Email" value="<?php if (isset($_POST['email'])) echo $_POST['email']; ?>" ></div>
<div class="mb-3"><input class="form-control" type="tel" id="phone" name="phone" placeholder="Phone Number" value="<?php echo $_SESSION['init']['phone']; ?>" disabled></div>
<div class="mb-3"><input class="form-control" type="password" name="password" placeholder="Password" minlength="8" required></div>
<div class="mb-3"><input class="form-control" type="password" name="re_password" placeholder="Password (repeat)" ></div>
<div class="mb-3">
<div class="form-check"><label class="form-check-label"><input class="form-check-input" type="checkbox"required>I agree to the license terms.</label></div>
</div>
<div class="mb-3"><button class="btn btn-primary d-block w-100" type="submit" name="signup" required>Sign Up</button></div>
</form>
</div>
</section>
</div>
Now, create a file named signin.php in the root directory and paste the following code into it:
<?php
require_once('process.php')
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Sign in-OTP</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Karla">
<link rel="stylesheet" href="assets/styles.css">
</head>
<body style="font-family: Karla, sans-serif;">
<div class="page">
<section class="register">
<div class="form-container">
<form method="post">
<h2 class="text-center"><strong>Sign in</strong> to your account.</h2>
<?php if ($err_message) : ?>
<div class="callout text-danger">
<p><?php echo $err_message; ?></p>
</div>
<?php endif; ?>
<div class="mb-3"><input class="form-control" type="email" name="email" placeholder="Email"></div>
<div class="mb-3"><input class="form-control" type="password" name="password" placeholder="Password"></div>
<div class="mb-3"><button class="btn btn-primary d-block w-100" type="submit" name="signin">Sign in</button></div><a class="already" href="signup.php">Don't have an account? Sign up here.</a>
</form>
</div>
</section>
</div>
</body>
</html>
Then, create a file named styles.css in the assets folder and paste the following code in to it:
body {
background: #f1f7fc;
}
.register {
padding: 80px 0;
}
.register .form-container {
display: table;
max-width: 500px;
width: 90%;
margin: 0 auto;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
}
.register form {
display: table-cell;
width: 400px;
background-color: #ffffff;
padding: 40px 60px;
color: #505e6c;
}
@media (max-width:991px) {
.register form {
padding: 40px;
}
}
.register form h2 {
font-size: 24px;
line-height: 1.5;
margin-bottom: 30px;
}
.register form .form-control {
background: #f7f9fc;
border: none;
border-bottom: 1px solid #dfe7f1;
border-radius: 0;
box-shadow: none;
outline: none;
color: inherit;
text-indent: 6px;
height: 40px;
}
.register form .form-check {
font-size: 13px;
line-height: 20px;
}
.register form .btn-primary {
background: #f4476b;
border: none;
border-radius: 4px;
padding: 11px;
box-shadow: none;
margin-top: 35px;
text-shadow: none;
outline: none !important;
}
.register form .btn-primary:hover,
.register form .btn-primary:active {
background: #eb3b60;
}
.register form .btn-primary:active {
transform: translateY(1px);
}
.register form .already {
display: block;
text-align: center;
font-size: 12px;
color: #6f7a85;
opacity: 0.9;
text-decoration: none;
}
Following that, create a file named process.php in the project's root directory, and add the following code to it. This code is responsible for processing all requests from the sign in and signup pages.
<?php
//Create Session
require_once("config.php");
ob_start();
session_start();
if (isset($_POST['telsign'])) {
$phone = $_POST['tel'];
$statement = $pdo->prepare("SELECT * FROM user WHERE tel=?");
$statement->execute(array($_POST['tel']));
$total = $statement->rowCount();
if ($total) {
$valid = 0;
$err_message .= 'An account is already associated with the provided Phone Number<br>';
}
else{
$verification = $twilio->verify->v2->services($serviceId)
->verifications
->create($phone, "call");
if ($verification->status) {
$_SESSION['phone']= $phone;
header("Location: verify.php?p=signup");
exit();
} else {
echo 'Unable to send verification code';
}
}
}
if (isset($_POST['signup_verify'])) {
$code = $_POST['code'];
$phone = $_SESSION['phone'];
$verification_check = $twilio->verify->v2->services($serviceId)
->verificationChecks
->create(
$code,
["to" => "+" . $phone]
);
if ($verification_check->status == 'approved') {
$_SESSION['init']['status'] = 'success';
$_SESSION['init']['phone'] = $phone;
header("location: signup.php?p=step2");
} else {
$err_message .= 'Invalid code entered<br>';
}
}
//If the Signup button is triggered
if (isset($_POST['signup'])) {
$valid = 1;
/* Validation to check if fullname is inputed */
if (empty($_POST['fullName'])) {
$valid = 0;
$err_message .= " FullName can not be empty<br>";
}
//Filtering fullname if not empty
else {
$fullname = filter_var($_POST['fullName'], FILTER_SANITIZE_STRING);
}
/* Validation to check if email is inputed */
if (empty($_POST['email'])) {
$valid = 0;
$err_message .= 'Email address can not be empty<br>';
}
/* Validation to check if email is valid */
else {
if (filter_var($_POST['email'], FILTER_VALIDATE_EMAIL) === false) {
$valid = 0;
$err_message .= 'Email address must be valid<br>';
}
/* Validation to check if email already exist */ else {
// Prepare our SQL, preparing the SQL statement will prevent SQL injection.
$statement = $pdo->prepare("SELECT * FROM user WHERE email=?");
$statement->execute(array($_POST['email']));
$total = $statement->rowCount();
//If There is a match gives an error message
if ($total) {
$valid = 0;
$err_message .= 'An account is already asociated with the provided email address<br>';
}
}
}
/* Validation to check if password is inputed */
if (empty($_POST['password']) || empty($_POST['re_password'])) {
$valid = 0;
$err_message .= "Password can not be empty<br>";
}
/* Validation to check if passwords match*/
if (!empty($_POST['password']) && !empty($_POST['re_password'])) {
if ($_POST['password'] != $_POST['re_password']) {
$valid = 0;
$err_message .= "Passwords do not match<br>";
}
//If Passwords Matches Generates Hash
else {
//Generating Password hash
$password = filter_var($_POST['password'], FILTER_SANITIZE_STRING);
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
}
}
if ($valid == 1) {
//if There Error Messages are empty Store data in the database
if (empty($err_message)) {
//Saving Data Into Database
$statement = $pdo->prepare("INSERT INTO user (fullName,email,tel,password) VALUES (?,?,?,?)");
$statement->execute(array($fullname, $_POST['email'], $_POST['tel'], $hashed_password));
unset($_SESSION['phone']);
header("location: signin.php");
} else {
$err_message .= "Problem in registration.Please Try Again!";
}
}
}
if (isset($_POST['signin'])) {
//Checks if Both input fields are empty
if (empty($_POST['email'] || empty($_POST['password']))) {
$err_message .= 'Email and/or Password can not be empty<br>';
}
//Checks if email is valid
else {
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if ($email === false) {
$err_message .= 'Email address must be valid<br>';
} else {
//Checks if email exists
$statement = $pdo->prepare("SELECT * FROM user WHERE email=?");
$statement->execute(array($email));
$total = $statement->rowCount();
$result = $statement->fetchAll(PDO::FETCH_ASSOC);
if ($total == 0) {
$err_message .= 'Email Address does not match<br>';
} else {
//if email exists save all data in the same column of the email in the row array
foreach ($result as $row) {
$stored_password = $row['password'];
}
}
}
//Checks Provided password matches the password in database if it does logs user in
if (password_verify($_POST['password'], $stored_password)) {
//setting the session variables
$_SESSION['user'] = $row;
$_SESSION['user']['status'] = '';
$_SESSION['phone'] = $row['tel'];
//setting the session signin time
$_SESSION['user']['loggedin_time'] = time();
$verification = $twilio->verify->v2->services($serviceId)
->verifications
->create($_SESSION['phone'], "sms");
if ($verification->status) {
header("Location: verify.php?p=signin");
exit();
}
} else {
$err_message .= 'Password does not match<br>';
}
}
}
if (isset($_POST['signin_verify'])) {
$code = $_POST['code'];
$phone = $_SESSION['phone'];
$verification_check = $twilio->verify->v2->services($serviceId)
->verificationChecks
->create(
$code,
["to" => "+" . $phone]
);
if ($verification_check->status == 'approved') {
$_SESSION['user']['status'] = 'success';
header("Location: index.php");
//destroy created session
} else {
$err_message .= 'Invalid code entered<br>';
}
}
Stepping through the code, first, config.php, which contains the app's configuration settings, is included. Then, a session is created with a condition that checks if the provided number is not registered in the database. It then sends a verification code to the user, who is then directed to a page that verifies the authenticity of the code. If it is, then a session that allows the user to access the step2.phtml file, which collects the data which would be stored in the database, is created.
The next line verifies the form input and saves it to a database. If a user signs in successfully, they are routed to the verification page, and if authorized a session named user
and a sub-array called status
are initialized to success, allowing the user to access index.php, which is the dashboard.
Now, create three files in the root directory:
- index.php for the dashboard
- verify.php for inputting the verification code
- logout.php which signs out a user from the existing session
After they're created, paste the following code into index.php:
<?php
session_start();
//require_onces database config
require_once("config.php");
// Check if the user is logged in or not
if (!isset($_SESSION['user'])) {
header('location: signin.php');
exit;
}
// Check if the user has verified number or not
if ($_SESSION['user']['status']!=='success') {
header('location: signin.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>DashBoard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Karla">
<link rel="stylesheet" href="assets/styles.css">
</head>
<body style="font-family: Karla, sans-serif;">
<div class="page">
<section class="register">
<div class="form-container">
<h2 class="text-center"><strong>User </strong> DashBoard.</h2>
<div class="callout text-primary">
Welcome <?php echo $_SESSION['user']['fullName']; ?>. Click here to <a href="logout.php" tite="Logout">Logout.
</div>
</div>
</section>
</div>
</body>
</html>
Now, start PHP's built-in web server by running the following command:
php -S localhost:8000
Then, open http://localhost:8000/ in your browser, the sign in screen should look like:
In verify.php, paste the code below.
<?php require_once('process.php');
$page = ((empty($_GET['p'])) ? '' : strval($_GET['p']));
$number = $_SESSION['phone'];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Voice OTP</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Karla">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body style="font-family: Karla, sans-serif;">
<div class="page">
<?php if($page=='signup' && isset($_SESSION['phone'])): ?>
<section class="register">
<div class="form-container">
<form method="post">
<h2 class="text-center"><strong>Verify</strong> your account.</h2>
</p>A verification code has been sent through Voice call to
<b><?php echo mask_no($number); ?></b>, <br />
enter the code below to continue.</p>
<?php if ($err_message) : ?>
<div class="callout text-danger">
<p><?php echo $err_message; ?></p>
</div>
<?php endif; ?>
<div class="mb-3"><input class="form-control" type="text" name="code" placeholder="Code"></div>
<div class="mb-3"><button class="btn btn-primary d-block w-100" type="submit" name="signup_verify">Verify</button></div><a class="already" href="signup.php">Wrong Number?</a>
</form>
</div>
</section>
<?php elseif($page=='signin' && isset($_SESSION['phone'])):?>
<section class="register">
<div class="form-container">
<form method="post">
<h2 class="text-center"><strong>Verify</strong> your account.</h2>
</p>A verification code has been sent through Voice call to
<b><?php echo mask_no($number); ?></b>, <br />
enter the code below to continue.</p>
<?php if ($err_message) : ?>
<div class="callout text-danger">
<p><?php echo $err_message; ?></p>
</div>
<?php endif; ?>
<div class="mb-3"><input class="form-control" type="text" name="code" placeholder="Code"></div>
<div class="mb-3"><button class="btn btn-primary d-block w-100" type="submit" name="signin_verify">Verify</button></div>
</form>
</div>
</section>
<?php else: ?>
<?php header('HTTP/1.0 403 Forbidden'); echo 'Error Cannot access resource'; ?>
<?php endif; ?>
</div>
</body>
</html>
The Verification screen:
Parameters were set in place to prevent a user from accessing the verify.php page, if they haven't gone through the verification process. And in logout.php, paste the code below.
<?php
ob_start();
session_start();
//includes app config file
require_once('config.php');
//Unset the session variable
unset($_SESSION['user']);
//destroy created session
session_destroy();
// Redirect to login page
header("location: signin.php");
Note
If you are making calls from a trial account, the "To" phone number must be verified with Twilio. You can verify your phone number by adding it to your Verified Caller IDs in the console.
That's how to use Twilio Verify to deliver an OTP via calls with PHP.
There you have it, a mechanism to encrypt crucial activities in your app using one-time passwords sent via phone calls to users. In this tutorial, you learned how to send an OTP via a phone call with Twilio verify. However, Verify has a great deal more functionality than has been covered in this tutorial. Check out Twilio’s Verify docs if you're keen to learn more about the available functionality. For your convenience, the whole code for this article is accessible on Github.
If you enjoyed this piece you can follow me on Twitter @ _yongdev