25.5 Signed URLs and Signed Cookies: Restricting Content to Authorized Users
Right, so you’ve built something brilliant, and now you want to put it behind a velvet rope. Maybe it’s a premium video course, a paid report, or a members-only cat meme repository. The point is, you need to serve content from CloudFront but only to people who have paid the bouncer. This is where Signed URLs and Signed Cookies come in. They’re two sides of the same coin, both using cryptographic signatures to grant temporary access. The choice between them isn’t about security—they’re equally secure—it’s about the user experience you’re trying to create.
Signed URLs: The Precise Sledgehammer
A Signed URL is exactly what it sounds like: a standard CloudFront URL with a bunch of extra query parameters that form a cryptographic signature. When you sign a URL, you’re generating a unique key for that single, specific resource. This is your go-to tool when you’re granting access to an individual file.
Think of it like a key that only opens one specific door, one time, and expires after a set period. It’s perfect for a direct download link you’d email to a user, or for a ‘Download Now’ button that fetches a specific asset. The downside? If your page has 50 assets (images, CSS, JS), you’d need to generate 50 unique signed URLs, which is a massive pain for your server and a terrible user experience. Don’t do that. For that scenario, you want cookies.
Here’s how you generate one using the AWS SDK for Python (Boto3). Notice the policy statement: this is the heart of the operation. You’re defining the “who, what, when” of the access.
import boto3
from datetime import datetime, timedelta
from botocore.signers import CloudFrontSigner
# 1. Load your private key (keep this secret, obviously!)
with open('private_key.pem', 'rb') as key_file:
private_key = key_file.read()
# 2. Create a CloudFront signer. We use a helper function to sign with the RSA key.
def rsa_signer(message):
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
# The key was loaded as bytes, so we need to parse it properly
key = load_pem_private_key(private_key, password=None, backend=default_backend())
return key.sign(message, padding.PKCS1v15(), hashes.SHA1())
# Your CloudFront Key Pair ID from the AWS console
key_id = 'APKAEXAMPLE12345'
cloudfront_signer = CloudFrontSigner(key_id, rsa_signer)
# 3. Create the signed URL
url = "https://d1234567890abc.cloudfront.net/path/to/secret_cat_meme.jpg"
expiry_date = datetime.utcnow() + timedelta(minutes=10) # URL expires in 10 minutes
signed_url = cloudfront_signer.generate_presigned_url(
url, date_less_than=expiry_date
)
print(signed_url)
The resulting URL will be a monstrosity with Expires, Signature, and Key-Pair-Id parameters. That’s the proof. CloudFront’s edge locations know how to validate this signature against the public key that corresponds to your private key.
Signed Cookies: The Session Pass
Signed Cookies solve the “multiple assets” problem beautifully. Instead of signing every single URL, you sign a set of cookies that you place on the user’s browser. Once those cookies are set, every subsequent request to your CloudFront distribution from that browser automatically includes them. CloudFront validates the cookies and, if they check out, serves the requested content.
This is the equivalent of giving someone a wristband for an all-access pass. They flash the wristband at every door (asset request) instead of fumbling for a new key each time. It’s ideal for serving a whole webpage or a video stream broken into multiple segments.
The code to generate the cookie policy is almost identical to the URL signing, but you get back a dict of cookies to set.
# ... (same setup as previous example with private_key and key_id) ...
# Create a policy that defines the access rules
policy = cloudfront_signer.build_policy(
"https://d1234567890abc.cloudfront.net/path/to/members/*", # Resource path (can use wildcards!)
date_less_than=expiry_date
).encode('utf8')
# Generate the signature for the policy
signature = cloudfront_signer._sign(policy)
# The cookies you need to set on the client
cookies = {
'CloudFront-Policy': cloudfront_signer._url_b64encode(policy).decode('utf8'),
'CloudFront-Signature': cloudfront_signer._url_b64encode(signature).decode('utf8'),
'CloudFront-Key-Pair-Id': key_id
}
# Now, in your HTTP response to the authorized user, set these three cookies.
# Their browser will handle the rest.
The Crucial Setup: Trusting Your Own Signature
Here’s the part everyone messes up. It’s not enough to just generate these signatures. You have to tell CloudFront to actually check for them. In your Cache Behavior settings, you must restrict viewer access. This is the master switch. You choose either “Yes” for signed URLs or signed cookies, and then you specify the trusted key pairs that can sign these requests.
If you forget this step, you’ll have a perfectly signed URL that CloudFront will happily ignore, serving your “private” content to anyone who asks. I’ve done it. You’ll do it. It’s a rite of passage.
Why You’ll Probably Prefer Cookies (And One Big Gotcha)
For most web applications, Signed Cookies are the right choice. The user experience is seamless. The big technical “gotcha” is that cookies are sent for every request to your domain. This means if you have a members-only area at /members/* but also a public marketing site at /* on the same CloudFront distribution, your user’s browser will send the access cookies for your public images and CSS too. This is inefficient and unnecessary.
The best practice here is to use a separate distribution (or at least a separate cache behavior with a different path pattern) for your private content. Isolate the stuff that needs the wristband from the stuff that doesn’t. It’s cleaner, more secure, and slightly less annoying for your user’s browser.