PHP Email Verification

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.phpCode 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=e01e5c9a028d58d888ff2555b971c882Code 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.

Did you find this tutorial useful?