How to use CloudFront signed URLs
Secure content delivery for serverless apps
Overview
CloudFront signed URLs allow access to a path under a distribution. The backend has access to the private part of a key pair and its public counterpart is added to CloudFront as a trusted key. Then whenever the backend wants to allow a user to access a path it constructs a special URL. Part of this URL is signed with the private key, which makes it unforgable to parties not having the private key.
Then when the client uses the signed URL, it goes directly to CloudFront that checks if the signature is valid and the policy allows access. If everything checks out, the request is processed as normal.
For example, in a photo-sharing app where users can upload public and private images, access control is a crucial aspect. Here, the backend can decide for each user whether they are allowed to access a particular photo, usually based on a value in a database, and if access is granted then return the signed URL.
Signed URLs is a crucial aspect in serverless applications as these apps need to follow the "small and quick" response model. That means an arbitrarily large file might go over a quota and instead of a photo the client would get an error message. With signed URLs the files can be of any size while the responses from the backend will be small.
An example signed URL:
https://d1jqwpmsk09v42.cloudfront.net/images/OLnBaWJJj4k
?Expires=1689090900
&Signature=FmPvubkxydNahd9AAoybPU0Fs4NXcbFD17jEv~2AKA5EpxpXmknbA-A~09KZ49kAa1u85PY4RbZqoL0j0DZ2-IZh21zd~nlalIczGXbMCboz2efj1jcP63vmA78XwiOgvP4dn8M0fUqf~HWKxidoNLQZyU7Wiz8SnAcmG5EEhqTcuTcr1ldJ1KARvseJ0JK9Ep6zd3RpzkqV7cOXHpe83Y969c~fgzKcmtjBrxtonqlT~bdKIvcV6CRXMAR-~iUA6CNwpxpOMSO8OgpXV6mM8Pldpryq4ttzb-y6RgA8PYX~7LAzhJbb-kI2nklWbrx4~W6fkJpDOOnJJsFUAC2rzw__
&Key-Pair-Id=K8ERN3QV51Q10
Key pair
CloudFront signing is based on a public-private key pair. The public part is configured in the distribution and that tells CloudFront what keys to accept for the signatures.
Here's an example public key:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdefUfqciFIFKCKp6g1I
tHhoMb1VaBwXr82GZnjaswB2kfjx0kbsKPdATKAd2k9AlJ88ovO8MPDBFe9aLWUc
0123UXzJ+tG0rpb+37fFs083reX8c7BcTg+R7b4JQkfHkcUuCzIGHpEJsSMJalgR
J3ONSDoEFeETGOo2GzanW75F7zvw4kVL2mwH+x7f/cMpgmwB8saRsZbPrImM/6/K
QmOU36mxati2rOehKefjvMsKRTzx+UMjn90s8EtKY31dz4nNsYNd+/fU5Pfrt8zo
cK/yd7bddmNGVekoAdJ18dgaBPa6TAg4z8MvIL2NQeM9Cj+wj/aL+28oHduDRndz
qQIDAQAB
-----END PUBLIC KEY-----
The private key is used for the signature and must be kept secret.
Here's an example private key (redacted because it's long):
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDF159R+pyIUgUo
IqnqDUi0eGgxvVVoHBevzYZmeNqzAHaR+PHSRuwo90BMoB3aT0CUnzyi87ww8MEV
71otZRzTXbdRfMn60bSulv7ft8WzTzet5fxzsFxOD5HtvglCR8eRxS4LMgYekQmx
IwlqWBEnc41IOgQV4RMY6jYbNqdbvkXvO/DiRUvabAf7Ht/9wymCbAHyxpGxls+s
...
-----END PRIVATE KEY-----
Some approaches to generate and store these keys: Using Terraform and Using KMS.
Infrastructure
To use the key, CloudFront needs to know about it. Historically, it meant adding it to the AWS account itself with the root user, but fortunately it's not the case anymore. Now any user with the necessary permissions can manage these keys.
CloudFront keys use an indirection called "Key groups". Individual keys are added to these groups and the groups are added to a cache behavior.
First, the public key needs to be added to CloudFront:
resource "aws_cloudfront_public_key" "generated_key" {
encoded_key = jsondecode(module.cf_key.outputs).publicKey
}
Then the key is added to a key group:
resource "aws_cloudfront_key_group" "cf_keygroup" {
items = [
aws_cloudfront_public_key.generated_key.id,
]
name = "${random_id.id.hex}-group"
}
Finally, the key group is defined for a cache behavior:
ordered_cache_behavior {
path_pattern = "/protected/*"
# ...
trusted_key_groups = [aws_cloudfront_key_group.cf_keygroup.id]
}
This setup provides a lot of flexibility. A distribution can protect certain paths while leaving open other ones. For example, the /
and the /api/
paths could be open to all, but the /files/
could be protected.
Signing the URL
Now that we have everything configured, let's see how the signing process works.
The signature process involves a policy that defines what paths are accessible, what is the validity time, and optionally limits the IP adresses of the clients.
Policy
CloudFront supports a simplified policy scheme called "canned policies" that only allows setting the expiration time and the path. To use all the features of CloudFront signed URLs, you can opt for the "custom policies" in which case you can define wildcards for the URL, a start time when the URL becomes usable, and an IP address allowlist. An advantage of a canned policy is that the policy itself is not part of the URL, while for custom policies it has to be encoded and attached as a query parameter.
In my experience, the canned policies are usually enough and that also means the resulting URLs will be shorter.
The "hidden" policy for the above URL looks like this:
{
"Statement": [
{
"Resource": "https://d1jqwpmsk09v42.cloudfront.net/images/OLnBaWJJj4k",
"Condition": {
"DateLessThan": {
"AWS:EpochTime": 1689090900
}
}
}
]
}
Signature
This policy document is minified, hashed, signed, then some of its characters are replaced with URL-safe variants, and finally added to the URL as the
Signature
query parameter.
This signature is generated on the backend and is a call to the utility function provided by the AWS SDK. For example, in NodeJs, it looks like this:
import {getSignedUrl} from "@aws-sdk/cloudfront-signer";
return getSignedUrl({
url: url,
keyPairId: keyPairId,
dateLessThan: expiration,
privateKey: privateKey,
});
Alternatively, you can achieve the same with just the built-in crypto
module:
import crypto from "node:crypto";
const expires = Math.round(expiration.getTime() / 1000);
const policy = {
Statement: [
{
Resource: url,
Condition: {
DateLessThan: {
"AWS:EpochTime": expires,
}
}
}
]
};
const sign = crypto.createSign("SHA1");
sign.write(JSON.stringify(policy));
sign.end();
const signature = sign.sign(privateKey).toString("base64");
return `${url}?Expires=${expires}&Signature=${signature.replaceAll("+", "-").replaceAll("=", "_").replaceAll("/", "~")}&Key-Pair-Id=${process.env.KEYPAIR_ID}`;
After the signed URL is generated, the backend sends it back to the client.