Summary: in this tutorial, you’ll learn how to verify the new account’s email address securely using an activation link.
Introduction to the PHP email verification for new accounts
In previous tutorials, you learned how to create a registration form that allows users to register for accounts. And you also learned how to build a login form that will enable users to use the username and password to sign in.
When users register for new accounts, they enter their email addresses. However, users can enter any email address because the system does not verify email.
To verify users’ email addresses, you can send a verification email to these email addresses and request users to open their emails and click an activation link.
To do it, you follow the following steps when users register accounts:
- Generate a unique activation code and set an expiration time, e.g., one day.
- Save the user record into the database and mark the user’s status as inactive. Also, save the hash of the activation code & expiration time.
- Send an email with the activation link to the user’s email address. The activation link will contain the email address and activation code, e.g.,
https://app.com/activate.php?email=email&activation_code=abcd
- Inform the user to activate the account via email.
Hashing the activation code ensures that only the user who owns the email address can activate the account, not anyone else, even the admin, who can access the database.
If users have not activated account, they will not be able to log in.
When users click the activation link in the email, you need to perform the following steps:
- Sanitize and validate the email and activation code.
- Find the inactive user with the email address. If no user record exists, redirect to the registration form.
- If a user record exists and the activation code is expired, delete the user record from the database and redirect to the registration form.
- Otherwise, match the activation code with the hash of the activation code stored in the database. If they match, mark the user record as active and redirect to the login page.
Recreate the users table
First, drop the users
table from the auth
database:
DROP TABLE users;
Code language: SQL (Structured Query Language) (sql)
Second, create the users
table with the new columns active
, activation_code
, activation_at
, activation_expiry
:
CREATE TABLE users
(
id int auto_increment PRIMARY KEY,
username varchar(25) NOT NULL,
email varchar(255) NOT NULL,
password varchar(255) NOT NULL,
is_admin tinyint(1) NOT NULL DEFAULT 0,
active tinyint(1) DEFAULT 0,
activation_code varchar(255) NOT NULL,
activation_expiry datetime NOT NULL,
activated_at datetime DEFAULT NULL,
created_at timestamp NOT NULL DEFAULT current_timestamp(),
updated_at datetime DEFAULT current_timestamp() ON UPDATE current_timestamp()
);
Code language: SQL (Structured Query Language) (sql)
The following explains the meaning of the new columns.
The value of the active
column defaults to 0. This means that users who register for accounts but haven’t verified their email addresses will be inactive by default.
The activation_code
column will store the hash of the activation code. Its length should be sufficient to store the string returned by the password_hash()
function.
It’s important to notice that the hash will be truncated if the activation_code
column doesn’t have a long enough size. It’ll cause the password_verify()
function to fail to match the activation code with the hash.
The activation_expiry
column stores the expiration time to use the activation code before expiry. The expiration time ensures that the activation code cannot be used if the email address is compromised after the expiration time.
The activated_at
column stores the date and time when users activate their accounts.
Project structure
Let’s review the current project structure before adding the email verification functions:
├── config
| ├── app.php
| └── database.php
├── public
| ├── index.php
| ├── login.php
| ├── logout.php
| └── register.php
└── src
├── auth.php
├── bootstrap.php
├── inc
| ├── footer.php
| └── header.php
├── libs
| ├── connection.php
| ├── filter.php
| ├── flash.php
| ├── helpers.php
| ├── sanitization.php
| └── validation.php
├── login.php
└── register.php
Code language: PHP (php)
Modify the functions in auth.php file
The following adds the activation code and expiry parameter to the register_user()
function. By default, the expiration time is one day ( 1 * 24 * 60 * 60
).
function register_user(string $email, string $username, string $password, string $activation_code, int $expiry = 1 * 24 * 60 * 60, bool $is_admin = false): bool
{
$sql = 'INSERT INTO users(username, email, password, is_admin, activation_code, activation_expiry)
VALUES(:username, :email, :password, :is_admin, :activation_code,:activation_expiry)';
$statement = db()->prepare($sql);
$statement->bindValue(':username', $username);
$statement->bindValue(':email', $email);
$statement->bindValue(':password', password_hash($password, PASSWORD_BCRYPT));
$statement->bindValue(':is_admin', (int)$is_admin, PDO::PARAM_INT);
$statement->bindValue(':activation_code', password_hash($activation_code, PASSWORD_DEFAULT));
$statement->bindValue(':activation_expiry', date('Y-m-d H:i:s', time() + $expiry));
return $statement->execute();
}
Code language: PHP (php)
The register_user()
function uses the password_hash()
function to hash the activation code.
The find_user_by_username()
function includes the active
column in the result:
function find_user_by_username(string $username)
{
$sql = 'SELECT username, password, active, email
FROM users
WHERE username=:username';
$statement = db()->prepare($sql);
$statement->bindValue(':username', $username);
$statement->execute();
return $statement->fetch(PDO::FETCH_ASSOC);
}
Code language: PHP (php)
The following defines a new function is_user_active()
that returns true if a user is active:
function is_user_active($user)
{
return (int)$user['active'] === 1;
}
Code language: PHP (php)
The login()
function should allow only active users to sign in:
function login(string $username, string $password): bool
{
$user = find_user_by_username($username);
if ($user && is_user_active($user) && password_verify($password, $user['password'])) {
// prevent session fixation attack
session_regenerate_id();
// set username in the session
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
return true;
}
return false;
}
Code language: PHP (php)
Define functions that deal with email verification
We’ll add the functions that deal with email verification to the auth.php
file.
First, create a new file app.php
in the config
folder and define the following constants:
<?php
const APP_URL = 'http://localhost/auth';
const SENDER_EMAIL_ADDRESS = '[email protected]';
Code language: PHP (php)
We’ll use these constants for sending activation emails to users. To use these constants, you need to include the app.php
file in the bootstrap.php
file:
<?php
session_start();
require_once __DIR__ . '/../config/app.php';
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/libs/helpers.php';
require_once __DIR__ . '/libs/flash.php';
require_once __DIR__ . '/libs/sanitization.php';
require_once __DIR__ . '/libs/validation.php';
require_once __DIR__ . '/libs/filter.php';
require_once __DIR__ . '/libs/connection.php';
require_once __DIR__ . '/auth.php';
Code language: PHP (php)
Second, define a function that generates a uniquely random activation code:
function generate_activation_code(): string
{
return bin2hex(random_bytes(16));
}
Code language: PHP (php)
Third, define a function that sends an email verification with an activation link.
function send_activation_email(string $email, string $activation_code): void
{
// create the activation link
$activation_link = APP_URL . "/activate.php?email=$email&activation_code=$activation_code";
// set email subject & body
$subject = 'Please activate your account';
$message = <<<MESSAGE
Hi,
Please click the following link to activate your account:
$activation_link
MESSAGE;
// email header
$header = "From:" . SENDER_EMAIL_ADDRESS;
// send the email
mail($email, $subject, nl2br($message), $header);
}
Code language: PHP (php)
Suppose the app’s URL is http://localhost/auth
, the activation URL will look like this:
http://localhost/auth/[email protected]&activation_code=e01e5c9a028d58d888ff2555b971c882
Code language: PHP (php)
The send_activation_email()
function uses the built-in mail()
function for sending emails.
Fourth, define a function that deletes a user by id and status. By default, it deletes an inactive user by id.
function delete_user_by_id(int $id, int $active = 0)
{
$sql = 'DELETE FROM users
WHERE id =:id and active=:active';
$statement = db()->prepare($sql);
$statement->bindValue(':id', $id, PDO::PARAM_INT);
$statement->bindValue(':active', $active, PDO::PARAM_INT);
return $statement->execute();
}
Code language: PHP (php)
Fifth, define a function that finds an unverified user by an email and activation code. If the activation code is expired, the function also deletes the user record by calling the delete_user_by_id()
function.
function find_unverified_user(string $activation_code, string $email)
{
$sql = 'SELECT id, activation_code, activation_expiry < now() as expired
FROM users
WHERE active = 0 AND email=:email';
$statement = db()->prepare($sql);
$statement->bindValue(':email', $email);
$statement->execute();
$user = $statement->fetch(PDO::FETCH_ASSOC);
if ($user) {
// already expired, delete the in active user with expired activation code
if ((int)$user['expired'] === 1) {
delete_user_by_id($user['id']);
return null;
}
// verify the password
if (password_verify($activation_code, $user['activation_code'])) {
return $user;
}
}
return null;
}
Code language: PHP (php)
Sixth, define a new activate_user()
function that activates a user by an id:
function activate_user(int $user_id): bool
{
$sql = 'UPDATE users
SET active = 1,
activated_at = CURRENT_TIMESTAMP
WHERE id=:id';
$statement = db()->prepare($sql);
$statement->bindValue(':id', $user_id, PDO::PARAM_INT);
return $statement->execute();
}
Code language: PHP (php)
Modify the register.php page
The src/register.php
needs to incorporate the logic to handle the email verification logic.
<?php
if (is_user_logged_in()) {
redirect_to('index.php');
}
$errors = [];
$inputs = [];
if (is_post_request()) {
$fields = [
'username' => 'string | required | alphanumeric | between: 3, 25 | unique: users, username',
'email' => 'email | required | email | unique: users, email',
'password' => 'string | required | secure',
'password2' => 'string | required | same: password',
'agree' => 'string | required'
];
// custom messages
$messages = [
'password2' => [
'required' => 'Please enter the password again',
'same' => 'The password does not match'
],
'agree' => [
'required' => 'You need to agree to the term of services to register'
]
];
[$inputs, $errors] = filter($_POST, $fields, $messages);
if ($errors) {
redirect_with('register.php', [
'inputs' => escape_html($inputs),
'errors' => $errors
]);
}
$activation_code = generate_activation_code();
if (register_user($inputs['email'], $inputs['username'], $inputs['password'], $activation_code)) {
// send the activation email
send_activation_email($inputs['email'], $activation_code);
redirect_with_message(
'login.php',
'Please check your email to activate your account before signing in'
);
}
} else if (is_get_request()) {
[$errors, $inputs] = session_flash('errors', 'inputs');
}
Code language: PHP (php)
How it works.
First, generate an activation code:
$activation_code = generate_activation_code();
Code language: PHP (php)
Second, register the user with the activation code:
register_user($inputs['email'], $inputs['username'], $inputs['password'], $activation_code)
Code language: PHP (php)
Third, send an email to the user’s email address by calling the send_activation_email()
function:
send_activation_email($inputs['email'], $activation_code);
Code language: PHP (php)
Finally, redirect the user to the login page and show a flash message that requests the user to activate the account via email:
redirect_with_message(
'login.php',
'Please check your email to activate your account before signing in'
);
Code language: PHP (php)
Create the activate.php page
To allow users to activate their accounts after registration, you can create a new activate.php
page in the public
folder and use the following page:
<?php
require __DIR__ . '/../src/bootstrap.php';
if (is_get_request()) {
// sanitize the email & activation code
[$inputs, $errors] = filter($_GET, [
'email' => 'string | required | email',
'activation_code' => 'string | required'
]);
if (!$errors) {
$user = find_unverified_user($inputs['activation_code'], $inputs['email']);
// if user exists and activate the user successfully
if ($user && activate_user($user['id'])) {
redirect_with_message(
'login.php',
'You account has been activated successfully. Please login here.'
);
}
}
}
// redirect to the register page in other cases
redirect_with_message(
'register.php',
'The activation link is not valid, please register again.',
FLASH_ERROR
);
Code language: PHP (php)
How the activate.php works.
First, sanitize and validate the email and activation code:
[$inputs, $errors] = filter($_GET, [
'email' => 'string | required | email',
'activation_code' => 'string | required'
]);
Code language: PHP (php)
Second, find the unverified user based on the email and verification code if there are no validation errors. The find_unverified_user()
will also delete the unverified user if the expiration time is expired.
$user = find_unverified_user($inputs['activation_code'], $inputs['email']);
Code language: PHP (php)
Third, activate the user and redirect to the login.php page:
if ($user && activate_user($user['id'])) {
redirect_with_message(
'login.php',
'You account has been activated successfully. Please login here.'
);
}
Code language: PHP (php)
Finally, redirect to the registration.php
if there’s an error:
redirect_with_message(
'register.php',
'The activation link is not valid, please register again.',
FLASH_ERROR
);
Code language: PHP (php)
In this tutorial, you’ve learned how to implement email verification for user accounts in PHP.