PHP - - By Timothy Boronczyk

Generating One-Time Use URLs

A one-time URL is a specially crafted address that is valid for one use only. It’s usually provided to a user to gain privileged access to a file for a limited time or as part of a particular activity, such as user account validation. In this article I’ll show how to generate, implement, and expire one-time URLs.

Creating a URL

Let’s say we’ve been tasked with writing a user management component, and the project creates a record for a new user account in the database. After signing up, the user receives a confirmation email which provides a one-time URL to activate her account. We can make this URL one-time by including a tracked token parameter, which means the URL would look like this:

http://example.com/activate?token=ee97780...

Not surprisingly, tracking the one-time URL is done by storing information in a database table. So, let’s start with the table definition:

CREATE TABLE pending_users (
    token CHAR(40) NOT NULL,
    username VARCHAR(45) NOT NULL,
    tstamp INTEGER UNSIGNED NOT NULL,
    PRIMARY KEY(token)
);

The table stores the relevant username, a unique token, and a timestamp. I’ll be showing how to generate the token using the sha1() function, which returns a 40-character string, hence the capacity of the token field as 40. The tstamp field is an unsigned integer field used to store a timestamp indicating when the token was generated and can be used if we want to implement a mechanism by which the token expires after a certain amount of time.

There are many ways to generate a token, but here I’ll simply use the uniqid() and sha1() functions. Regardless of how you choose to generate your tokens, you’ll want them to be unpredictable (random) and a low chance of duplication (collision).

<?php
$token = sha1(uniqid($username, true));

uniqid() accepts a string and returns a unique identifier based on the string and the current time in microseconds. The function also accepts an optional Boolean argument to add additional entropy to make the result more unique. The sha1() function calculates the hash of the given string using the US Secure Hash Algorithm 1.

Once the functions execute, we’ve got a unique 40-character string which we can use as our token to create the one-time URL. We’ll want to record the token along with the username and timestamp in the database so we can reference it later.

<?php
$query = $db->prepare(
    "INSERT INTO pending_users (username, token, tstamp) VALUES (?, ?, ?)"
);
$query->execute(
    array(
        $username,
        $token,
        $_SERVER["REQUEST_TIME"]
    )
);

Obviously we want to store the token, but we also store the username to remember which user to set active, and a timestamp. In a real world application you’d probably store the user ID and reference a user record in a separate user table, but I’m using the username string for example’s sake.

With the information safely placed in the database, we can now construct our one-time URL which points to a script that receives the token and processes it accordingly.

<?php
$url = "http://example.com/activate.php?token=$token";

The URL can be disseminated to the user either by email or some other means.

<?php
$message = <<<ENDMSG
Thank you for signing up at our site.  Please go to
$url to activate your account.
ENDMSG;

mail($address, "Activate your account", $message);

Consuming a URL

We need a script to activate the account once the user follows the link. Indeed, it’s the processing script that works that enforces the one-time use of the URL. What this means is the script will need to glean the token from the calling URL and do a quick check against the data stored in the database table. If it’s a valid token, we can perform whatever action we want, in this case setting the user active and expiring the token.

<?php
// retrieve token
if (isset($_GET["token"]) && preg_match('/^[0-9A-F]{40}$/i', $_GET["token"])) {
    $token = $_GET["token"];
}
else {
    throw new Exception("Valid token not provided.");
}

// verify token
$query = $db->prepare("SELECT username, tstamp FROM pending_users WHERE token = ?");
$query->execute(array($token));
$row = $query->fetch(PDO::FETCH_ASSOC);
$query->closeCursor();

if ($row) {
    extract($row);
}
else {
    throw new Exception("Valid token not provided.");
}

// do one-time action here, like activating a user account
// ...

// delete token so it can't be used again
$query = $db->prepare(
    "DELETE FROM pending_users WHERE username = ? AND token = ? AND tstamp = ?",
);
$query->execute(
    array(
        $username,
        $token,
        $tstamp
    )
);

Going further, we could enforce a 24-hour TTL (time to live) for the URL buy checking the timestamp stored in the table alongside the token.

<?php
// 1 day measured in seconds = 60 seconds * 60 minutes * 24 hours
$delta = 86400;

// Check to see if link has expired
if ($_SERVER["REQUEST_TIME"] - $tstamp > $delta) {
    throw new Exception("Token has expired.");
}
// do one-time action here, like activating a user account
// ...

Working within the realm of Unix timestamps, the expiration date would be expressed as an offset in seconds. If the URL is only supposed to be valid for 24 hours, we have a window of 86,400 seconds. Determining if the link has expired then becomes a simple matter of comparing the current time with the original timestamp and see if the difference between them is less than the expiration delta. If the difference is greater than the delta, then the link should be expired. If the difference is less than or equal to the delta, the link is still “fresh.”

Conclusion

There are several applications for one-time use URLs. The example in this article was a scenario of sending a user a verification link to activate an account. You could also use one-time use URLs to provide confirmation for other activities, give time-sensitive access to information, or to create timed user accounts which expire after a certain time.

As a matter of general house keeping you could write a secondary script to keep expired tokens from accumulating in the database if a user never follows them. The script could be run periodically by an administrator, or preferably set up as a scheduled task or cron job and run automatically.

It would also be wise to take this functionality and wrap it up into a reusable component as you implemented it in your application. It’s trivial to do, and so I’ll leave that as an exercise to the reader.

Image via Fotolia

Sponsors