Generating One-Time URLs with PHP

Imagine that you're selling a digital product online. Maybe you've written
an article or a book and want to sell it on your site as a PDF. There are many
ways one could do this, but one of the more convenient is to provide the user
with a unique URL that only will work a limited number of times. This URL
could, for example, be presented to the user (your client) on the last page of
an orderflow, after payment has been made. We will look at code to generate a
unique URL that will work a single time.

Creating the URL

The script generate_url.php will generate our URLs. It uses
PHP's md5() and uniqid() functions to create a
unique, long and complicated token which will act as the key to the file to
protect. Tokens are 32 random characters long, so to figure this out by mental
arithmetic would be impossible. The listing below shows you the implementation
of generate_url.php.

In short terms, md5() calculates a 32-character long
hexadecimal number by using the RSA Data Security, Inc. MD5
Message-Digest Algorithm out of the unique string generated by
uniqid(). Note that we also feed uniqid() with a
random value. You see, uniqid() generates a unique id based on
the current time in microseconds. Although small, there's always a possibility
that the script is executed two or more times simultaneously, which could
result in the same id being generated more than once. By feeding
uniqid() with a random value generated by rand(), we
push the risk of not getting a really unique id closer to zero. Or, as stated
in the PHP reference documentation, this generates a token "that is extremely
difficult to predict". If you need more documentation on md5() and
uniqid(), please consult the PHP reference documentation at http://www.php.net/manual/en/function.md5.php
and http://www.php.net/manual/en/function.uniqid.php.

After generating the unique id, we open the file /tmp/urls.txt
for writing. This file is used to store the unique tokens, one per line. When
writing to this file, it's very important the we do real file-locking to
prevent two processes writing the file at the same time. We do this by using
PHP's flock() function. See http://www.php.net/manual/en/function.flock.php
for more information about file-locking with PHP.

Finally, on the last three lines, the newly generated URL is presented to
the client. It should show something like Figure 1.

Figure 1 -- A new and unique id is generated.

Sending Out the Secret File

As you saw in the previous section, there's reference to a file called
get_file.php. This script takes care of the verification of a
generated token, and, if the token is valid, sends out the secret file to the
user. This file also deletes a token from /tmp/urls.txt after it
has been used. See the listing below.

<?
/*
* get_file.php
*
* Script for validating a request through a secret token, passing a file
* to the user, and ensuring the token can not be used again.
*
*/
/* Retrive the given token: */
$token = $_GET['q'];
if( strlen($token)<32 )
{
die("Invalid token!");
}
/* Define the secret file: */
$secretfile = "/tmp/secret_file.txt";
/* This variable is used to determine if the token is valid or not: */
$valid = 0;
/* Define what file holds the ids. */
$file = "/tmp/urls.txt";
/* Read the whole token-file into the variable $lines: */
$lines = file($file);
/* Truncate the token-file, and open it for writing: */
if( !($fd = fopen("/tmp/urls.txt","w")) )
die("Could not open $file for writing!");
/* Aquire exclusive lock on $file. */
if( !(flock($fd,LOCK_EX)) )
die("Could not equire exclusive lock on $file!");
/* Loop through all tokens in the token-file: */
for( $i = 0; $lines[$i]; $i++ )
{
/* Is the current token the same as the one defined in $token? */
if( $token == rtrim($lines[$i]) )
{
$valid = 1;
}
/* The code below will only get executed if $token does NOT match the
current token in the token file. The result of this will be that
a valid token will not be written to the token file, and will
therefore only be valid once. */
else
{
fwrite($fd,$lines[$i]);
}
}
/* We're done writing to $file, so it's safe release the lock. */
if( !(flock($fd,LOCK_UN)) )
die("Could not release lock on $file!");
/* Save and close the token file: */
if( !(fclose($fd)) )
die("Could not close file pointer for $file!");
/* If there was a valid token in $token, output the secret file: */
if( $valid )
{
readfile($secretfile);
}
else
{
print "Invalid URL!";
}
?>

This script requires a token to be passed to it via the GET method. If a
valid token is not provided, execution is terminated directly. This is a very
important security issue. What would happen if an empty token was sent to the
script and /tmp/urls.txt had an empty line on the end? Again,
note the use of flock() to accomplish file-locking. This is, as in
generate_url.php, an important feature for securing the file-handling.

Every line in urls.txt is then compared to the token that was
sent to the script. If the token is actually present in urls.txt,
the script marks the request as valid by setting $valid to 1. If
the request is not valid, $valid will keep the value 0, and the
secret file will not get passed to the user. Also note that all tokens that do
not match the request are written to urls.txt again, but tokens
that do match the request are skipped and, by that, removed from
urls.txt .

Place get_file.php and generate_url.php on your
Web server and click on the link shown in Figure 2. Depending on what is in
your /tmp/secret_file.txt, you should now see something like
Figure 2.

Figure 2 -- the super-secret contents of the super-secret file

As you see, get_file.php approved the request and presented us
with the contents of /tmp/secret_file.txt. However, if you now try
to reload the page, get_file.php will block the request. See
Figure 3.

Figure 3 -- secret files without secret keys

Viola. Mission accomplished.

Summary

This article has presented a quick tip of how you could generate URLs that
only can be used one time. It should be said, however, that for a real
implementation of this function in a business environment, there are a few
additional considerations to take. When the number of active keys grows past a
few hundred, is it a good idea to read the whole file directly into memory? Is
it wise even to store them in a plain-text file in the first place? Maybe a
MySQL database would be a better choice? Additionally, consider the possibility
of getting tokens "hijacked" directly from the urls.txt file. For
getting this 100 percent secure, you either need to protect the file by setting very
restrictive permission on it, only allowing the user executing your PHP scripts
to read it. Or, you can simply dump the plain-text solution, and convert to a
SQL-database instead.

The code in generate_url.php, of course has to be protected, too.
The easiest way of doing that is probably to include it in the
"thank-you-for-ordering-page" of your orderflow and make this file accessible
only for clients coming from the secure-payment-complete-page. The core concept,
though, will probably work very well in any production environment.

Daniel Solin
is a freelance writer and Linux consultant whose specialty is GUI programming. His first book, SAMS Teach Yourself Qt Programming in 24 hours, was published in May, 2000.