Double Opt-In Subscription Form with Secure Hash using PHP

Last modified on September 24th, 2019 by Vincy.

Do you know that the opening rate of emails by double opt-in confirmed subscribers is a staggering 40%? According to CampaignMonitor, email marketing generates $38 in ROI for every $1 spent.

Email marketing delivers the highest among any channel for marketing. Even in comparison with channels like print, TV and social media.

Double Opt-In Subscription Form with Secure Hash using PHP

Email marketing is the way to go. The primary mode to build your list is using a double opt-in subscription form.

What is inside?

  1. Why do we need double opt-in?
  2. What is the role of secure hash?
  3. Double opt-in subscription form in PHP
  4. Sequence flow for double opt-in subscription
  5. Double opt-in Subscription form UI
  6. PHP AJAX for subscription form submission
  7. URL with secure hash
  8. A PHP utility class for you
  9. Store subscription information to database
  10. A database abstraction layer for you
  11. Send confirmation email to users
  12. Subscription confirmation
  13. Conclusion

Use a double opt-in subscription form to signup to a newsletter, blog or a similar service. It has a two-step subscription process.

In the first step, the user will submit his name and email. Then the site will send an email to the user.

In the second step, the user will click the link in the received email. This will confirm his subscription to the site or service.

We call it the double opt-in because the users consent to the subscription twice. First by submitting the information and second by confirming to in by clicking the link in email.

Why do we need double opt-in?

It is the mechanism used to verify if the subscriber owns the input email. You need to do this verification because there is a chance for misuse by submitting emails that they do not own.

Double opt-in vs single opt-in is well debated and results arrived at. Double opt-in wins hands-on in every critical aspect.

What is the role of a secure hash?

In the confirmation email received by the user, there will be a link. This is the second and important step in the opt-in process. The link should be secure.

  • It should be unique for every user and request.
  • It should not be predictable.
  • It should be immune to a brute-force attack.

Double opt-in subscription form in PHP

I will present you a step by step detail on how to build a double opt-in subscription form with a secure hash using PHP.

You will get a production-grade code which you can use real-time in your live website. You can use this to manage your newsletter subscription.

I am releasing this code to you under MIT license. You can use it free even in commercial projects.

Sequence flow for double opt-in subscription

  1. Show a subscription form to the user.
  2. On AJAX submit, insert a new record in the database.
  3. Send an email to the user with a secure hash link.
  4. On click, the of the link, update the subscription status.
  5. On every step, there will be appropriate validations in place.

Double opt-in Subscription form UI

This is where developers get it wrong. Keep it simple and unobtrusive. For the high conversion, you must keep in minimal.

One field email is enough for the subscription. To address the user in a personal way, you need their name. That’s it. Do not ask for much information on a subscription form.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
	content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="author" content="Vincy">
<link rel="stylesheet" type="text/css"
	href="assets/css/phppot-style.css">
<title>Double Opt-In Subscription Form with Secure Hash using PHP</title>
</head>
<body>
	<div class="phppot-container">
		<h1>Double Opt-in Subscription</h1>
		<form class="phppot-form" action="" method="POST">
			<div class="phppot-row">
				<div class="label">
					Name
				</div>
				<input type="text" id="pp-name" name="pp-name"
					class="phppot-input">
			</div>
			<div class="phppot-row">
				<div class="label">
					Email *
					<div id="email-info" class="validation-message"
						data-required-message="required."
						data-validate-message="Invalid email."></div>
				</div>
				<input type="text" id="pp-email" name="pp-email"
					class="required email phppot-input"
					onfocusout="return validateEmail();">
			</div>
			<div class="phppot-row">
				<button type="Submit" id="phppot-btn-send">Subscribe</button>
				<div id="phppot-loader-icon">Sending ...</div>
				<div id="phppot-message"></div>
			</div>
		</form>
	</div>
<script src="vendor/jquery/jquery-3.3.1.js"></script>
<script src="assets/js/subscribe.js"></script></body>	
</body>
</html>

If you ask for much information, it will drive your users away. The same principle applies when you build a contact form. More or less these two behave in a similar aspect. Check how to build a contact form to know more on it.

Double Opt-in subscription form UI

You should leave the name field can as optional and only the email field should be as required. This will encourage the user to submit the form and subscribe for the newsletter.

Needless to say, the form should be responsive. Any page or form you build should work in mobile, tablet, laptop and desktop devices. You should optimize to work on any viewport.

Google parses webpages in mobile mode for indexing in the search result. The desktop is an old story and gone are those days. You should always design for the mobile. Make it mobile-first!

PHP AJAX for subscription form submission

I have used AJAX to manage the submission. This will help the user to stay on the page after subscription. You can position this subscription form in a sidebar or the footer.

Double Opt-in Subscription Form AJAX Submission

This is a classic example of where you should use the AJAX. I have seen instances where people use AJAX in inappropriate places, for the sake of using it.

Subscription AJAX endpoint

The AJAX endpoint has three major steps:

  1. Verify the user input.
  2. Insert a record in the database.
  3. Send an email with a link for subscription.

subscribe-ep.php is the AJAX endpoint. It starts with an if condition to check if the submit is via the POST method. It is always good to program for POST instead of the GET by default.

<?php
use Phppot\Subscription;
use Phppot\SupportService;

/**
 * AJAX end point for subscribe action.
 * 1. validate the user input
 * 2. store the details in database
 * 3. send email with link that has secure hash for opt-in confirmation
 */
session_start();

// to ensure the request via POST
if ($_POST) {
    require_once __DIR__ . './../lib/SupportService.php';
    $supportService = new SupportService();

    // to Debug set as true
    $supportService->setDebug(false);

    // to check if its an ajax request, exit if not
    $supportService->validateAjaxRequest();

    require_once __DIR__ . './../Model/Subscription.php';
    $subscription = new Subscription();

    // get user input and sanitize
    if (isset($_POST["pp-email"])) {
        $userEmail = trim($_POST["pp-email"]);
        $userEmail = filter_var($userEmail, FILTER_SANITIZE_EMAIL);
        $subscription->setEmail($userEmail);
    } else {
        // server side fallback validation to check if email is empty
        $output = $supportService->createJsonInstance('Email is empty!');
        $supportService->endAction($output);
    }

    $memberName = "";
    if (isset($_POST["pp-name"])) {
        $memberName = filter_var($_POST["pp-name"], FILTER_SANITIZE_STRING);
    }
    $subscription->setMemberName($memberName);

    // 1. get a 12 char length random string token
    $token = $supportService->getToken(12);

    // 2. make that random token to a secure hash
    $secureToken = $supportService->getSecureHash($token);

    // 3. convert that secure hash to a url string
    $urlSecureToken = $supportService->cleanUrl($secureToken);
    $subscription->setSubsriptionKey($urlSecureToken);
    $subscription->setSubsciptionSatus(0);

    $currentTime = date("Y-m-d H:i:s");
    $subscription->setCreateAt($currentTime);
    $result = $subscription->insert();

    // check if the insert is success
    // if success send email else send message to user
    $messageType = $supportService->getJsonValue($result, 'type');
    if ('error' != $messageType) {
        $result = $subscription->sendConfirmationMessage($userEmail, $urlSecureToken);
    }
    $supportService->endAction($result);
}

I have used the SupportService class to perform common functions.

Input sanitisation is a must. When you collect information using a public website, you should be careful. You could get infected without your knowledge. There are many bots foraging around the Internet and they click on all links and buttons.

To sanitise, do not invent a new function. Use the function provided by PHP and that is safe to use.

URL with secure hash

Generate a unique url for each user subscription. Use this url to confirm the user’s subscription in the second step. Remember, that’s why we call this double opt-in.

I have used a three step process:

  1. Generate a random string token.
  2. Convert the token to secure hash.
  3. Convert the secure hash to safe url.

I have used hexdec, bin2hex and openssl_random_pseudo_bytes to generate random bits. Which forms a random string.

Then to make the random string a secure hash, I have used the PHP’s built-in password_hash function. Never every try to do something on your own. Go with the PHP’s function and it does the job very well.

Before PHP 7, we had the option to supply a user generated salt. Now PHP 7 release has deprecated it. It is a good move because, PHP can generate a better salt than what you will generate. So stick to PHP 7 and use it without supplying your own salt.

The secure hash will contain all sort of special characters. . You can keep those special characters but need to url encode it. But I always wish to keep urls clean and the encoded chars do not look nice.

So no harm in removing them. So I cleanup those and leave only the safe characters. Then as a secondary precaution, I also encode the resultant string.

Thus after going through multi step process, we get a random, hash secure, safe, encoded URL token. Save the user submitted information in database record along with this token.

A PHP utility class for you

This is a utility class which I use in my projects. I am giving it away free for you all. It has functions that I reuse quite often and will be handy in situations. Every method has detailed comments that explain their purpose and usage method.

<?php
/**
 * Copyright (C) 2019 Phppot
 *
 * Distributed under MIT license with an exception that,
 * you don’t have to include the full MIT License in your code.
 * In essense, you can use it on commercial software, modify and distribute free.
 * Though not mandatory, you are requested to attribute this URL in your code or website.
 */
namespace Phppot;

class SupportService
{

    /**
     * Short circuit type function to stop the process flow on validation failure.
     */
    public function validateAjaxRequest()
    {
        // to check if its an ajax request, exit if not
        $http_request = $_SERVER['HTTP_X_REQUESTED_WITH'];
        if (! isset($http_request) && strtolower($http_request) != 'xmlhttprequest') {
            $output = $this->createJsonInstance('Not a valid AJAX request!');
            $this->endAction($output);
        }
    }

    /**
     * Last point in the AJAX work flow.
     * Clearing tokens, handles and resource cleanup can be done here.
     *
     * @param string $output
     * @param boolean $clearToken
     */
    public function endAction($output)
    {
        die($output);
    }

    public function setDebug($mode)
    {
        if ($mode == true) {
            ini_set('display_errors', 1);
            set_error_handler(function ($severity, $message, $file, $line) {
                if (error_reporting() & $severity) {
                    throw new \ErrorException($message, 0, $severity, $file, $line);
                }
            });
        }
    }

    /**
     * encodes a message string into a json object
     *
     * @param string $message
     * @param string $type
     * @return \JsonSerializable encoded json object
     */
    public function createJsonInstance($message, $type = 'error')
    {
        $messageArray = array(
            'type' => $type,
            'text' => $message
        );
        $jsonObj = json_encode($messageArray);
        return $jsonObj;
    }

    public function getJsonValue($json, $key)
    {
        $jsonArray = json_decode($json, true);
        return $jsonArray[$key];
    }

    /**
     * If you are using PHP, this is the best possible secure hash
     * do not try to implement somthing on your own
     *
     * @param string $text
     * @return string
     */
    public function getSecureHash($text)
    {
        $hashedText = password_hash($text, PASSWORD_DEFAULT);
        return $hashedText;
    }

    /**
     * generates a random token of the length passed
     *
     * @param int $length
     * @return string
     */
    public function getToken($length)
    {
        $token = "";
        $codeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
        $codeAlphabet .= "abcdefghijklmnopqrstuvwxyz";
        $codeAlphabet .= "0123456789";
        $max = strlen($codeAlphabet) - 1;
        for ($i = 0; $i < $length; $i ++) {
            $token .= $codeAlphabet[$this->cryptoRandSecure(0, $max)];
        }
        return $token;
    }

    public function cryptoRandSecure($min, $max)
    {
        $range = $max - $min;
        if ($range < 1) {
            return $min; // not so random...
        }
        $log = ceil(log($range, 2));
        $bytes = (int) ($log / 8) + 1; // length in bytes
        $bits = (int) $log + 1; // length in bits
        $filter = (int) (1 << $bits) - 1; // set all lower bits to 1
        do {
            $rnd = hexdec(bin2hex(openssl_random_pseudo_bytes($bytes)));
            $rnd = $rnd & $filter; // discard irrelevant bits
        } while ($rnd >= $range);
        return $min + $rnd;
    }

    /**
     * makes the passed string url safe and return encoded url
     *
     * @param string $str
     * @return string
     */
    public function cleanUrl($str, $isEncode = 'true')
    {
        $delimiter = "-";
        $str = str_replace(' ', $delimiter, $str); // Replaces all spaces with hyphens.
        $str = preg_replace('/[^A-Za-z0-9\-]/', '', $str); // allows only alphanumeric and -
        $str = trim($str, $delimiter); // remove delimiter from both ends
        $regexConseqChars = '/' . $delimiter . $delimiter . '+/';
        $str = preg_replace($regexConseqChars, $delimiter, $str); // remove consequtive delimiter
        $str = mb_strtolower($str, 'UTF-8'); // convert to all lower
        if ($isEncode) {
            $str = urldecode($str); // encode to url
        }
        return $str;
    }

    /**
     * to mitigate XSS attack
     */
    public function xssafe($data, $encoding = 'UTF-8')
    {
        return htmlspecialchars($data, ENT_QUOTES | ENT_HTML401, $encoding);
    }

    /**
     * convenient method to print XSS mitigated text
     *
     * @param string $data
     */
    public function xecho($data)
    {
        echo $this->xssafe($data);
    }
}

Store subscription information to the database

Insert a record to the database on submission of the subscription form. We get the user’s name, email, generate a secure hash token, current time, subscription status.

<?php
/**
 * Copyright (C) 2019 Phppot
 * 
 * Distributed under MIT license with an exception that, 
 * you don’t have to include the full MIT License in your code.
 * In essense, you can use it on commercial software, modify and distribute free.
 * Though not mandatory, you are requested to attribute this URL in your code or website.
 */
namespace Phppot;

use Phppot\DataSource;

class Subscription
{

    private $ds;

    private $memberName;

    private $email;

    private $subsriptionKey;

    private $subsciptionSatus;

    private $createAt;

    private $supportService;

    function __construct()
    {
        require_once __DIR__ . './../lib/DataSource.php';
        $this->ds = new DataSource();

        require_once __DIR__ . './../lib/SupportService.php';
        $this->supportService = new SupportService();
    }

    public function getMemberName()
    {
        return $this->memberName;
    }

    public function getEmail()
    {
        return $this->email;
    }

    public function getSubsriptionKey()
    {
        return $this->subsriptionKey;
    }

    public function getSubsciptionSatus()
    {
        return $this->subsciptionSatus;
    }

    public function getCreateAt()
    {
        return $this->createAt;
    }

    public function setMemberName($memberName)
    {
        $this->memberName = $memberName;
    }

    public function setEmail($email)
    {
        $this->email = $email;
    }

    public function setSubsriptionKey($subsriptionKey)
    {
        $this->subsriptionKey = $subsriptionKey;
    }

    public function setSubsciptionSatus($subsciptionSatus)
    {
        $this->subsciptionSatus = $subsciptionSatus;
    }

    public function setCreateAt($createAt)
    {
        $this->createAt = $createAt;
    }

    /**
     * to get the member record based on the subscription_key
     *
     * @param string $subscriptionKey
     * @return array result record
     */
    public function getMember($subscriptionKey, $subscriptionStatus)
    {
        $query = 'SELECT * FROM tbl_subscription where subscription_key = ? and subscription_status = ?';
        $paramType = 'si';
        $paramValue = array(
            $subscriptionKey,
            $subscriptionStatus
        );
        $result = $this->ds->select($query, $paramType, $paramValue);
        return $result;
    }

    public function insert()
    {
        $query = 'INSERT INTO tbl_subscription (member_name, email, subscription_key, subscription_status, create_at) VALUES (?, ?, ?, ?, ?)';
        $paramType = 'sssis';
        $paramValue = array(
            $this->memberName,
            $this->email,
            $this->subsriptionKey,
            $this->subsciptionSatus,
            $this->createAt
        );
        $insertStatus = $this->ds->insert($query, $paramType, $paramValue);
        return $insertStatus;
    }

    public function updateStatus($subscriptionKey, $subscriptionStatus)
    {
        $query = 'UPDATE tbl_subscription SET subscription_status = ? WHERE subscription_key = ?';
        $paramType = 'is';
        $paramValue = array(
            $subscriptionStatus,
            $subscriptionKey
        );
        $this->ds->execute($query, $paramType, $paramValue);
    }

    /**
     * sends confirmation email, to keep it simple, I am just using the PHP's mail
     * I reccommend serious users to change it to PHPMailer and set
     * appropriate headers
     */
    public function sendConfirmationMessage($mailTo, $urlSecureToken)
    {
        // following is the opt-in url that will be sent in email to
        // the subscriber. Replace example.com with your server
        $confirmOptInUrl = 'http://example.com/confirm.php?q=' . $urlSecureToken;
        $message = '<p>Howdy!</p>
        <p>This is an automated message sent for subscription service.
You must confirm your request to subscribe to example.com site.</p>
        <p>Website Name: example</p>
        <p>Website URL: http://example.com</p>
        <p>Click the following link to confirm: ' . $confirmOptInUrl . '</p>';

        $isSent = mail($mailTo, 'Confirm your subscription', $message);

        if ($isSent) {
            $message = "An email is sent to you. You should confirm the subscription by clicking the link in the email.";
            $result = $this->supportService->createJsonInstance($message, 'message');
        } else {
            $result = $this->supportService->createJsonInstance('Error in sending confirmation email.', 'error');
        }
        return $result;
    }
}

The reason for storing the current time is to have an expiry for every link. We can set a predefined expiry for the double opt-in process.

For example, you can set one week as expiry for a link from the moment you generate it. The user has to click and confirm before that expiry period.

Subscription status is by default stored as ‘0’ and on confirmation changed to ‘1’.

A database abstraction layer for you

It is my PHP abstraction for minor projects. This works as a layer between controller, business logic and the database. It has generic methods using which we can to the CRUD operations. I have bundled it with the free project download that is available at the end of this tutorial.

Send confirmation email to users

After you insert the record, send an email will to the user to perform the double opt-in confirmation. The user will have a link in the email which he has to click to confirm.

Keep the email simple. It is okay to have text instead of fancy HTML emails. PHP is capable of generating any email and you can code complex email templates. But the spam engines may not like it.

Subscription information database record with secure hash

If you wish to go with HTML emails, then keep the HTML code ratio to as least as possible. As this is also one factor using which the spam engines flag the emails.

Then remember not to use the spam stop words. There are words like “free”, “win”, “cash”, “promo” and “income”. There is a long list and you can get it on the Internet by searching for “email spam filter word list”.

I have used PHP’s mail() function to send the email. I recommend you to change it to PHPMailer to send SMTP based email if you plan to use this code in production.

Subscription confirmation

Create a public landing page and you may use .htaccess for a neat URL mapping. This URL should map with the URL sent to the user and the PHP file that is going to process the request.

As a first step, GET the token and to verify the user against the database. Check,

  1. if such a token exists,
  2. it is not expired,
  3. the user is not already subscribed
  4. add more validation as you deem fit.
<?php
use Phppot\Subscription;
use Phppot\SupportService;

/**
 * For confirmation action.
 * 1. Get the secure has from url
 * 2. validate it against url
 * 3. update the subscription status in database accordingly.
 */
session_start();

// to ensure the request via POST
require_once __DIR__ . '/lib/SupportService.php';
$supportService = new SupportService();

// to Debug set as true
$supportService->setDebug(true);

$subscriptionKey = $_GET['q'];

require_once __DIR__ . '/Model/Subscription.php';
$subscription = new Subscription();
$result = $subscription->getMember($subscriptionKey, 0);

if (count($result) > 0) {
    // member found, go ahead and update status
    $subscription->updateStatus($subscriptionKey, 1);
    $message = $result[0]['member_name'] . ', your subscription is confirmed.';
    $messageType = 'success';
} else {
    // securiy precaution: do not reveal any information here
    // play subtle with the reported message
    $message = 'Invalid URL!';
    $messageType = 'error';
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
	content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="author" content="Vincy">
<link rel="stylesheet" type="text/css"
	href="assets/css/phppot-style.css">
<title>Double Opt-In Subscription Confirmation</title>
</head>
<body>
	<div class="phppot-container">
		<h1>Double Opt-in Subscription Confirmation</h1>
		<div class="phppot-row">
			<div id="phppot-message" class="<?php echo $messageType; ?>"><?php echo $message;?></div>
		</div>
	</div>
</body>
</body>
</html>

If validation fails, do not reveal any information to the user. You should only say that it has failed.

Subscription double opt-in confirmation url verification

More important do not say, not such email found. This will allow finding who has subscribed to your service. Whenever a validation fails, the displayed message should not reveal internal information.

On validation success, update the subscription status. Then show a happy success message to the user.

Conclusion

I have presented you with a production-grade double opt-in subscription form. I have followed a most secure hash generation method for confirmation URL email. I present it to you under the MIT license. The intention is to be the most permissible. You can download it free and change the code. You can even use it in your commercial projects. I have used the most secure code as possible. You can use this in your live site to manage newsletter subscription. In the coming part, I will include unsubscribe and enhance it further. Leave your comments below with what sort of enhancements you are looking for.

Download

Comments to “Double Opt-In Subscription Form with Secure Hash using PHP”

  • Jerry says:

    OMG! OMG! Look at the level of detail. What a fantastic writeup. I am surprised at your focus on

    1) input sanitization
    2) security
    3) structuring the code
    4) focus on reusability

    Code that are available in the Internet in the name of tutorial sites are junk. They are litter, vomited with only ad-revenue (pageviews) in focus.

    Finding articles like these are getting diamonds from a mound of litter. Bravo! Send me a link, I wish to give you a donation. Really appreciate your work.

    Most important thing is that you have shared it under MIT license. Thank you for your service. God bless.

    • Vincy says:

      Saeed,

      If a functionality that sends email or stores records in database, then it is being abused by users. In order to protect the site, I wish not deploy demo for functionality with email or DB access.

      You can deploy it in couple of minutes in your system. If you face any issues in that let me know and I will help you out.

  • Chris says:

    This is Amaaaazing!!! Thank you so much :) I asked you a question in one of my other replies to one of your tutorials about the password hashing, and this answers my question. Kudos to the Master :)

  • Zafer says:

    Congratulations nice job

    Thank you

Leave a Reply

Your email address will not be published. Required fields are marked *

↑ Back to Top

Share this Article