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

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • NetSecPHP

    Your token generation algorithm is susceptible to timing attacks, and thus, in many applications a serious security flaw. I suggest you read this article http://phpsecurity.readthedocs.org/en/latest/Insufficient-Entropy-For-Random-Values.html#brute-force-attacking-unique-ids and update your post for the benefit of the greater PHP community.

  • http://spiechu.pl Spiechu

    Great article! Very useful in day-to-day programmer work.
    Token can be also associated with a file to one-time download.

  • http://blogdesignstudio.com Ravinder Kumar

    Great article!

  • Les

    Not bad as articles go, the two comments above are mute in my opinion as the algorithm is adequate in most cases although I would personally have used MD5( … ) inside the SHA1( … ), or have included an additional token just to help randomise the end token.

    • http://blog.astrumfutura.com Pádraic Brady

      @Les, the algorithm has two issues:
      1. It’s not protected against timing attacks which you need to be concious of given the latency between cloud nodes.
      2. uniqid() gets it’s extra entropy from a Linear Congruential Generator (LCG) which is effectively a call to lcg_value(). This is predictable if the seed is recoverable, e.g. uniqid() output elsewhere or a Session ID which also uses the same LCG.

      The first comment to the article points to my description of the problem with a run through of an attack targeting a password reset function.
      These are not moot points. They are very real and quite well documented at this point.

  • Raviraj

    Good Article

  • Christoph

    Great idea, Les! Let’s reduce the hash result space by using MD5 first. While we’re at it, we could also use crc32() first, to make it really uber secure and random. Oh you know what, let’s do sha1(md5(crc32($value ? 1 : 0))), because more is always better and i heard somewhere that the ternary operator is actually super secure. And since SHA-1 is sooo old and boring, let’s do sha512(sha1(md5(crc32($value?1:0)))). Now beat that, you evil hackers!

    And yes, adding another random value from the same source makes the resulting value even randomer. So if we add another random token, we get near infinite randomness! Because random + random = super random!

    Lord have mercy… Please listen to NetSecPHP and do the right thing, which is not bullshitting around with hash algorithms and trying to be uber smart by inventing your own stuff.