Protect Your Products and Improve Your Systems with Signed URLs

A lot of time is spent thinking about security and hardening sites against various attack vectors, sometimes at great expense. That being said, one of your most important assets could be vulnerable: Your downloadable products.

Fortunately, there is a better way to protect them using a method that goes by a few monikers, but we’ll call it URL Signing.

From the Beginning

So how exactly can URLs be handed out, while limiting access, even if it’s shared without permission, especially in a public location such as a forum or a blog?

One option is to move the location of the product in question when its location becomes known, but you’re really just playing a game of keep away.

http://example.com/product.ziphttp://example.com/product2.zip

A direct URL like that can be sent to anyone and everyone and you’d be none the wiser. What we really need to do is add some limiting factors to the query string that can be accessed on the server to determine whether or not the request should be fulfilled.

http://example.com/download.php?order=123&user=10&file=product.zip

Now on the server, we can check to make sure that the order with an ID of 123 was placed by a user with the ID of 10 and that the order contained product.zip before serving it. That helps a bit, but it’s easy enough for anyone poking around to change the order and user ID values until they find a valid combination, especially if there are only a few products available.

What about…?

The next thought might be to add something a little harder to guess, perhaps a random key so a valid combination couldn’t be easily guessed. The key has to be stored with each order so that it can be checked whenever the URL is requested. However, the URL can still be shared and downloaded by anyone in perpetuity. If the key becomes public, access would have to be cut off by changing the key or through some other manual process.

Adding an expiration date to the URL would help mitigate the life of the URL.

http://example.com/download.php?order=123&user=10&file=product.zip&key={{randomkey}}&expires={{timestamp}}

Now the “expires” timestamp can be checked on the server to determine whether or not the URL is still valid. It looks nice and secure, but all anyone really needs to do is change the timestamp to a date in the future and they’re back in business.

What if the expiration date is stored with the order instead? That would work since the query string can’t be tampered with, but once the order expires, it makes it difficult to grant access to the user that made the purchase without manually updating the order. That’s just extra code and data we don’t want to have to maintain.

What about obfuscating the parameters in the URL? That’s never really a valid approach to security and in open source software the algorithm can be easily discovered and reversed.

So how can we really ensure the data in the query string hasn’t been modified without storing information on the server to validate requests?

Signed, Sealed, Delivered

Essentially, after constructing the URL with any data that’s useful for verifying access, the URL can be “signed” by passing it through a hashing algorithm using a secret key.

In other words, if a random string that’s long enough in order to be incredibly difficult to duplicate is combined with the URL, then passed through a hash function, a signature (or token) can be generated to verify that the URL is the same when it’s requested as when it was handed it out.

http://example.com/download.php?order=123&user=10&file=product.zip&expires={{timestamp}}&token={{token}}

When the URL is requested, the token is copied and removed, then the remaining URL is passed through the same signing algorithm, using the same secret key that nobody else has access to, and the resulting token is compared to the one from the request to ensure they’re the same. If not, the URL has been modified and the request can short-circuited. No access for you!

Since it’s possible to ensure the URL hasn’t been modified, non-sensitive data can be passed in-the-clear via the URL without needing to be concerned with whether it’s valid when the request is made. Once the expiration date has passed, we know the URL can never be used again, even if it is published.

And because the limiting information like an expiration date doesn’t need to be stored with the order, URLs can be generated on the fly without storing keys on the server or jumping through hoops to provide temporary, secure access to a product.

Decoupling the URL from information on the server allows for much more security and flexibility in your system. Instead of downloads being tied directly to an order, a temporary URL can be handed out to an anonymous visitor who fills out a trial request form. Or support staff can provide a URL with limited access directly to a customer without worrying about additional points of failure, such as if the user isn’t logged in, can’t remember their password, etc. The possibilities are… limitless!?

Other Considerations

One additional bit of functionality that would need to be stored on the server is if you want to limit the number of times a file can be downloaded rather than relying solely on an expiration date. Every time a request is made, the token and a counter would need to be saved to the database, then once it has reached the allotted number of requests, you can prevent further access. When taking this route, I would suggest adding a limit query parameter with the number of downloads allowed as well as an expiration date. Passing the limit in the URL allows for variable limits and combining it with an expiration date means that you can clean up your database after a token has expired instead of storing request counters forever.

Ideally, either a limit or expiration date should be included with every signed URL, otherwise you’re back where you started if the URL is shared publicly. Anyone can access it and you’re left to take manual steps to disable it.

Signing URLs has other useful applications aside from protecting downloads and can be used to limit access for any request. For instance, some services use it for generating temporary URLs when users request a password reset.

Happy signing.

Code for WordPress and Easy Digital Downloads

While the technique can be used anywhere, I developed a few functions for WordPress (included below) to deliver plugin and theme downloads on AudioTheme, and here’s an extension for Easy Digital Downloads.

<?php
/**
* Basic URL Signing functions for WordPress.
*
* @author Brady Vercher (twitter.com/bradyvercher)
* @link http://www.blazersix.com/blog/protect-your-products-and-improve-your-systems-with-signed-urls/
*/
/**
* Sign a URL to ensure it hasn't been tampered with.
*
* @param string $url The URL to sign.
* @param array $args Optional. List of query args to add to the URL before signing.
* @return string Signed URL.
*/
function blazersix_sign_url( $url, $args = array() ) {
$args['token'] = false; // Remove a token if present.
$url = add_query_arg( $args, $url );
$token = blazersix_get_url_token( $url );
$url = add_query_arg( 'token', $token, $url );
return $url;
}
/**
* Generate a token for a given URL.
*
* An 'o' query parameter on a URL can include optional variables to test
* against when verifying a token without passing those variables around in
* the URL. For example, downloads can be limited to the IP that the URL was
* generated for by adding 'o=ip' to the query string.
*
* Or suppose when WordPress requested a URL for automatic updates, the user
* agent could be tested to ensure the URL is only valid for requests from
* that user agent.
*
* @param string $url The URL to generate a token for.
* @return string The token for the URL.
*/
function blazersix_get_url_token( $url ) {
$args = array();
// Add additional args to the URL for generating the token.
// Allows for restricting access to IP and/or user agent.
$parts = parse_url( $url );
$options = array();
if ( isset( $parts['query'] ) ) {
wp_parse_str( $parts['query'], $query_args );
// o = option checks (ip, user agent).
if ( ! empty( $query_args['o'] ) ) {
// Multiple options can be checked by separating them with a colon in the query parameter.
$options = explode( ':', rawurldecode( $query_args['o'] ) );
if ( in_array( 'ip', $options ) ) {
$args['ip'] = $_SERVER['REMOTE_ADDR'];
}
if ( in_array( 'ua', $options ) ) {
$args['user_agent'] = rawurlencode( $_SERVER['HTTP_USER_AGENT'] );
}
}
}
// Filter to modify arguments and allow custom options to be tested.
// Be sure to rawurlencode any custom options for consistent results.
$args = apply_filters( 'blazersix_get_url_token_args', $args, $url, $options );
$args['token'] = false; // Removes a token if present.
$parts = parse_url( add_query_arg( $args, $url ) );
$uri = $parts['path'];
$uri .= ( empty( $parts['query'] ) ) ? '' : '?' . $parts['query'];
$secret = apply_filters( 'blazersix_get_url_token_secret', wp_salt() );
$token = hash_hmac( 'md5', $uri, $secret );
return $token;
}
/**
* Generate a token for a URL and match it against the existing token to make
* sure the URL hasn't been tampered with.
*
* @param string $url URL to test.
* @return bool
*/
function blazersix_is_token_valid( $url ) {
$parts = parse_url( $url );
if ( isset( $parts['query'] ) ) {
wp_parse_str( $parts['query'], $query_args );
if ( isset( $query_args['token'] ) && $query_args['token'] == blazersix_get_url_token( $url ) ) {
return true;
}
}
return false;
}
view raw url-tokens.php hosted with ❤ by GitHub

  1. Aeguana Blog » Signed URls – Query string authentication
    […] you can use,  or you can implement signed URLs on your server. You can find out how to do this here on […]

  2. Mike

    I didn’t understand a lot of this, but you obviously know what you’re talking about. I’m going to have a site that will sell big downloadable files. Does this concept work if the customer doesn’t download his purchase until a later date? Do you take customers outside of your local area?