Skip to content

CloudFront signed URLs and signed cookies: protecting paid content properly

Learn how to protect paid downloads and member video with CloudFront signed URLs and signed cookies. This guide shows you how to set up the signing keys, choose between the two, and lock down your origin so nobody can skip CloudFront.

This guide is for you if you sell files or video through CloudFront, Amazon’s content delivery network (CDN), and want only paying customers to reach them. Without protection, anyone who finds a file’s link can share it or fetch it for free. CloudFront fixes this with signed URLs and signed cookies, which carry a time-limited signature that your application creates with a private key. Sign a URL for a single file, like a purchased PDF, and sign cookies for many files under one path, like the segments of a streaming video. You also need to lock your origin, the S3 bucket or server that holds the files, so nobody can skip CloudFront and download them directly.

What’s happening

When a cache behavior has Restrict viewer access enabled, CloudFront refuses any request that does not carry a valid signature — either in the URL’s query parameters (Expires/Policy, Signature, Key-Pair-Id) or in three cookies (CloudFront-Expires or CloudFront-Policy, CloudFront-Signature, CloudFront-Key-Pair-Id). Your application generates those signatures with an RSA private key; CloudFront checks them against public keys you uploaded under CloudFront > Key management > Public keys, grouped into key groups attached to the distribution.

Use key groups. The older mechanism — CloudFront key pairs created with the AWS account root user — still exists in old deployments, but it requires root credentials to rotate and cannot be managed with IAM. Key groups are plain API objects: any suitably-permissioned role can add and remove public keys, which is what makes rotation an operation instead of an event.

Policies come in two shapes. A canned policy encodes one resource URL and an expiry — compact, ideal for single-file URLs. A custom policy adds an optional start time, an optional source IP restriction, and wildcard resources (https://media.example.com/course-42/*) — required for signed cookies to cover a path, and the reason a cookie can open up a whole video stream.

The part teams forget: signing controls the CloudFront front door only. If the S3 bucket is public, or the ALB origin accepts direct traffic, the paid content is one curl to the origin away from free. Origin lockdown — origin access control for S3, a secret header or the CloudFront managed prefix list for custom origins — is half of the design, not an optional hardening step.

What to check first

  • Is Restrict viewer access on for the right behavior? Signing code without the behavior flag means everything is public; the flag without working signing code means everything is 403.
  • Is the public key in a key group attached to the distribution? A key uploaded but not in a trusted key group validates nothing.
  • Do clocks agree? Signature expiry is epoch-based; an application server with clock skew mints URLs that are dead on arrival.
  • Can the origin be reached directly? Test the S3 URL and the origin hostname from outside — both should refuse.

How to verify

# Sign a URL for one file with a canned policy (CLI supports this directly)
aws cloudfront sign \
  --url "https://media.example.com/downloads/report.pdf" \
  --key-pair-id K2JCJMDEHXQW5F \
  --private-key file://private_key.pem \
  --date-less-than 2026-07-05T12:00:00Z

# A valid signed URL returns 200; the same URL without parameters must 403
curl -s -o /dev/null -w '%{http_code}\n' "https://media.example.com/downloads/report.pdf"

# Confirm the key group wiring on the distribution
aws cloudfront get-distribution-config --id E1A2B3C4D5E6F7 \
  --query 'DistributionConfig.DefaultCacheBehavior.TrustedKeyGroups'

A MissingKey or Malformed Policy error page names the missing query parameter; a bare 403 Forbidden with x-cache: Error from cloudfront on a signed request usually means expiry passed, wrong key pair ID, or a URL that was re-encoded in transit (courriel clients love mangling long query strings — wrap download links).

The runbook

  1. Generate a 2048-bit RSA key pair and upload the public key. openssl genrsa -out private_key.pem 2048, extract the public half, upload it at CloudFront > Key management > Public keys, add it to a key group, attach the key group to the behavior. The private key goes into Secrets Manager or Parameter Store — never into the repo.
  2. Turn on Restrict viewer access per behavior, not globally. The /downloads/* and /streams/* behaviors get signing; marketing assets and the player page itself stay public. Mixed distributions are normal.
  3. Sign URLs server-side at request time with short expiries. Minutes for a download link a user clicks immediately; do not mint week-long URLs because they end up in forums. For resumable large downloads, size the expiry to the download time on a slow link, not to the click.
  4. Use signed cookies with a custom policy for streaming and multi-file paths. Set the three cookies on your application’s domain scoped to be sent to the media hostname, with the policy resource matching the path wildcard. The player then fetches segments with zero per-segment application work.
  5. Lock the origin. S3 origins get origin access control so only the distribution can read the bucket. Custom origins verify a secret custom header added by CloudFront, or restrict at the firewall to CloudFront’s origin-facing prefix list — the same lockdown logic as the WAF-on-ALB pattern where the edge must be the only door.
  6. Plan rotation before you need it. Add the new public key to the key group, deploy the application signing with the new private key, then remove the old public key after in-flight expiries pass. Key groups allow multiple keys precisely to make this overlap painless.

When this is the wrong move

Signed URLs authenticate possession of a link, not a person: anyone holding a live URL inside its window can use it, so they are the wrong tool for per-user authorization on long-lived resources. If the requirement is per-request user checks, do the check in the application or in edge code — a lightweight JWT check at the edge is one of the legitimate cases in the CloudFront Functions vs Lambda@Edge decision. Signing is also not DRM: a paying user can always save the bytes. And for a handful of internal files, S3 presigned URLs straight from the bucket are simpler when no CDN behavior is needed at all.

Common mistakes

  • Leaving the origin open, so the entire scheme protects the copy of the content nobody was going to fetch anyway.
  • Minting URLs with the account root’s legacy key pair, making rotation a root-credential ceremony.
  • Setting cookie scope wrong (domain or path), so the player sends no cookies and every segment 403s — the stream plays for the developer testing on the same domain, then fails in production.
  • Forgetting that cached 403s and clock skew look identical in bug reports: check Expires against server time first.

Prevention

  • Store the private key in Secrets Manager with access limited to the signing service role, and log its use.
  • Alarm on the distribution’s 403 rate; a spike is either an attack, an expiry bug, or a broken deploy of the signing code.
  • Keep two keys in the key group during any rotation window, and calendar the removal.
  • Test the direct-to-origin path from an external network every quarter; lockdowns rot when buckets get “temporarily” opened.

The policy statement format and cookie details are specified in the CloudFront documentation. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.