How to use CloudFront signed URLs

Secure content delivery for serverless apps

Author's image
Tamás Sallai
4 mins
Photo by moren hsu on Unsplash


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:

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:

-----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):


Some approaches to generate and store these keys: Using Terraform and Using KMS.


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
The public key added to CloudFront

Then the key is added to a key group:

resource "aws_cloudfront_key_group" "cf_keygroup" {
	items = [,
	name  = "${}-group"
The key group references the keys

Finally, the key group is defined for a cache behavior:

ordered_cache_behavior {
	path_pattern     = "/protected/*"
	# ...

	trusted_key_groups     = []
The cache behavior restricts viewer access

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.


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": "",
			"Condition": {
				"DateLessThan": {
					"AWS:EpochTime": 1689090900


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");
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.

September 19, 2023
In this article