Make Signed URLs for Amazon S3 with PHP

Signed URLs are public links which grant access to protected files stored in Amazon S3 buckets. With PHP you can dynamically generate these URL’s for use on your website. This article should help you get this process working.

Signed URL Basics

A basic Amazon S3 URL looks like this:

https://my-bucket.s3.amazonaws.com/media/test.mp3

A signed S3 URL looks like this:

https://my-bucket.s3.amazonaws.com/media/test.mp3?AWSAccessKeyId=AKIAN4H87JLSW90PQ6CN&Expires=1406326782&Signature=5xY0M%2BnppNCfhsjekXY5EeCmIVU%3D

Signed URLs are links to protected files, which under normal circumstance would produce little more than an Amazon error message but with the addition of specially encoded parameters, these same URLs can relay a range of access information to Amazon, allowing a web application to easily generate temporary links to secure files.

The encoded parameters contain information about the S3 object you are referencing like the object’s key, the public authentication credentials for the bucket, and the expiration datetime of the URL.

A number of request parameters can also be used in signed URLs to alter the response headers of the requested file, for instance, to force the download of a browser-playable file such as an image.

AWS Access Credentials

Your AWS access credentials file awskeys.php should be in a non-public directory on your server, and look something like this:

<?php
# File: /{some-non-public-server-dir}/awskeys.php
$awsAccessKey = 'AKIAN4H87JLSW90PQ6CN';
$secretKey = 'na7HdO3Pm&gdK2L0CHgq5hdKl+jHyaLkpT8M1s9iH'
?>

The AWS Signed URL Functionf

The S3 signed URL methods can be put in their own file like so:

<?php
# File: {any-path-you-like}/includes/s3-signed-urls.php

# Get the AWS access keys from a non-public server location.
include('/var/amazon/awskeys.php');
$bucket = 'my-bucket';

if(!function_exists('el_crypto_hmacSHA1')){
    /**
    * Calculate the HMAC SHA1 hash of a string.
    *
    * @param string $key The key to hash against
    * @param string $data The data to hash
    * @param int $blocksize Optional blocksize
    * @return string HMAC SHA1
    */
    function el_crypto_hmacSHA1($key, $data, $blocksize = 64) {
        if (strlen($key) > $blocksize) $key = pack('H*', sha1($key));
        $key = str_pad($key, $blocksize, chr(0x00));
        $ipad = str_repeat(chr(0x36), $blocksize);
        $opad = str_repeat(chr(0x5c), $blocksize);
        $hmac = pack( 'H*', sha1(
            ($key ^ $opad) . pack( 'H*', sha1(
                ($key ^ $ipad) . $data
            ))
        ));
        return base64_encode($hmac);
    }
}

if(!function_exists('getSignedUrl')){
    /**
    * Create signed URLs to your protected Amazon S3 files.
    *
    * @param string $awsAccessKey Your Amazon S3 access key
    * @param string $secretKey Your Amazon S3 secret key
    * @param string $bucket The bucket (mybucket.s3.amazonaws.com)
    * @param string $objectPath The target file path
    * @param int $expires In minutes
    * @param array $customParams Key value pairs of custom parameters
    * @return string Temporary signed Amazon S3 URL
    * @see http://awsdocs.s3.amazonaws.com/S3/20060301/s3-dg-20060301.pdf
    */
    function getSignedUrl($awsAccessKey, $secretKey, $bucket, $objectPath, $expires = 5, $customParams = array()) {
        
        # Calculate the expire time.
        $expires = time() + intval(floatval($expires) * 60);
        
        # Clean and url-encode the object path.
        $objectPath = str_replace(array('%2F', '%2B'), array('/', '+'), rawurlencode( ltrim($objectPath, '/') ) );
        
        # Create the object path for use in the signature.
        $objectPathForSignature = '/'. $bucket .'/'. $objectPath;
        
        # Create the S3 friendly string to sign.
        $stringToSign = implode("\n", $pieces = array('GET', null, null, $expires, $objectPathForSignature));
        
        # Create the URL frindly string to use.
        $url = 'http://' . $bucket . '.s3.amazonaws.com/' . $objectPath;
        
        # Custom parameters.
        $appendCharacter = '?'; // Default append character.
        
        # Loop through the custom query paramaters (if any) and append them to the string-to-sign, and to the URL strings.
        if(!empty( $customParams )){
                foreach ($customParams as $paramKey => $paramValue) {
                        $stringToSign .= $appendCharacter . $paramKey . '=' . $paramValue;
                        $url .= $appendCharacter . $paramKey . '=' . str_replace(array('%2F', '%2B'), array('/', '+'), rawurlencode( ltrim($paramValue, '/') ) );
                        $appendCharacter = '&';
                }
        }
        
        # Hash the string-to-sign to create the signature.
        $signature = el_crypto_hmacSHA1($secretKey, $stringToSign);
        
        # Append generated AWS parameters to the URL.
        $queries = http_build_query($pieces = array(
            'AWSAccessKeyId' => $awsAccessKey,
            'Expires' => $expires,
            'Signature' => $signature,
        ));
        $url .= $appendCharacter .$queries;
        
        # Return the URL.
        return $url;
        
    }
}

?>

To use signed URL’s, include the s3-signed-urls.php file and invoke the getSignedUrl() function to create your links:

<?php include('/includes/s3-signed-urls.php'); ?>
<a href="<?php echo getSignedUrl($awsAccessKey, $secretKey, $bucket, 'media/test.mp3'); ?>">test.mp3</a>

To use signed URL’s to change the default response headers for a file, for instance to force a download of playable file, do something like:

<?php include('/includes/s3-signed-urls.php'); ?>
<a href="<?php echo getSignedUrl(
    $awsAccessKey,
    $secretKey,
    $bucket,
    'media/test.mp3',
    '5',
    array( // Custom parameters to force a download and change the file name.
        'response-content-disposition' => 'attachment; filename=test (Download Version).mp3',
        'response-content-type' => 'application/octet-stream',
    )
); ?>">test.mp3</a>

Some Things to Note

Order of Custom Parameters

The order in which custom parameters are defined seems to matter. I have not got to spend a lot of time investigating this, but it seems at least for generating dynamic download headers, if I define the ‘response-content-type’ parameter before ‘response-content-disposition’, I get the standard S3 “SignatureDoesNotMatch” error code when I use the link. Switching the order so that ‘response-content-disposition’ comes first (alphabetical order maybe?) it works fine. I would love to learn more about this behavior.

Network Monitoring

When I look at the request generated under the “Network” tab of the built-in Google Chrome code inspector I get an interesting result. I see a standard GET entry, but it is highlighted red, like a failed request, but if I look under the “Status” heading for the row I see a “200 OK”. Under the “Size” heading it says “0 B”, what’s going on?

Credits
The solutions on this page are a reflection of my own experience with help from blog.5ubliminal.com via css-tricks.com, and a very informative post from stackoverflow.com.