CloudFront Signed URLs or Signed Cookies

July 07, 2027 · 13 min read

Developer · DVA-C02 · part of The Exam Room

The situation

A content platform runs a single CloudFront distribution in front of an S3 origin containing three kinds of files.

  • E-books: one PDF per purchase. Customer pays, gets a download link by email, clicks once (maybe twice if they’re cautious). The link should stop working after a short TTL or after the file is delivered.
  • Premium video library: 40,000 video files, multiple bitrate renditions each. Subscribers pay a monthly fee; any subscriber can request any video; the app plays HLS over many segment requests per minute of viewing.
  • Firmware updates: small binaries, fetched by embedded devices with constrained HTTP clients that don’t reliably persist cookies. The device authenticates itself with a signed token encoded in the URL and downloads the binary.

Today all three sets are served from public S3 URLs with ?token=... query strings that the application validates before redirecting, a pattern that worked in 2015 and has since become a CDN-performance problem. The team wants to move onto CloudFront-signed URLs or signed cookies and needs to figure out which one per flow.

What actually matters

Before reaching for the CloudFront console, it’s worth asking what we’re actually trading.

Both mechanisms use the same cryptographic shape: a private key held by the content owner, signing a policy that says “this identity may fetch these resources until this time.” The policy is either encoded into query parameters (signed URLs) or set as HTTP cookies (signed cookies); CloudFront validates the signature on each request and either serves the content or returns 403.

The first thing to ask is: how many URLs does the consumer need to access? A signed URL authorises one URL; every extra file needs its own signed URL. A signed cookie authorises a group of URLs that match a pattern (the CloudFront-Key-Pair-Id + policy covers a set of resources defined in the policy). If the consumer fetches one thing, signed URLs are fine; if it fetches many things, signed cookies save the trouble of signing each individually.

The second thing to ask is: does the client handle cookies reliably? Browsers do, and browsers are the usual consumer of signed cookies. Mobile apps do, with care. Low-level HTTP clients (in embedded devices, in some CLI tools, in certain HLS implementations) sometimes don’t. If the client can’t keep a cookie jar, signed URLs are the only option.

The third is how the URL is shared. A signed URL is a complete, self-contained link. Email it, text it, embed it in a page, it works. A signed cookie is per-session and can’t be forwarded usefully; sharing the URL without the cookie doesn’t grant access.

The fourth is what the policy covers. Signed URLs have a “canned” form (one resource, simple expiry) and a “custom” form (resource patterns, IP restriction, date-based validity window). Signed cookies only support the custom form, which means they can authorise wildcards (https://d123.cloudfront.net/subscribers/*) and the more expressive conditions.

The fifth is key management. Both mechanisms need a CloudFront public-key + key-group configured on the distribution. The private key signs; the distribution validates with the public key. Both mechanisms have the same key-management story; the only difference is what the signed output looks like.

And finally, a softer one: log and cache behaviour. Signed URLs include the signature in the query string; cache keys in CloudFront can include query strings, which means each unique signature is a separate cache entry unless the distribution is configured to forward only specific parameters. Signed cookies keep the URL clean and share cache entries across viewers whose cookies differ.

Side by side

Attribute Signed URL Signed cookies
Authorises One URL (canned) or pattern (custom) A pattern of URLs
Client must support HTTP client Cookies
Share the link Yes (contains everything) No (cookie is per-session)
Good for One-off downloads, device fetches Browsing a library of files
Cache-key effect Signature in query string Clean URL, better cache reuse
Policy types Canned or custom Custom only
Key-group Same Same
Revocation Shorten TTL, rotate keys Shorten TTL, rotate keys, clear cookie client-side

Reading the table by flow rather than by mechanism:

  • E-book download, one file per link, must be shareable by email, short TTL. Signed URL, canned policy, 10-minute expiry.
  • Premium video library, many files per session, browser consumer, pattern-based authorisation. Signed cookies on the subscribers/* prefix with a session-length TTL.
  • Firmware update, one file per device per version, device’s HTTP client may not handle cookies, delivery is by URL. Signed URL, custom policy with IP restriction matching the device’s expected carrier ranges.

Matching flow shape to mechanism

E-book, signed URL, one-shot, shareable by email App server generates signed URL email Customer clicks link GET /books/X.pdf?Signature=...&Expires=...&Key-Pair-Id=... self-contained, 10-minute TTL canned policy: one resource + expiry CloudFront validates sig → S3 S3 origin private bucket After 10 minutes, the same URL returns 403. Rotate keys to revoke early. Video library, signed cookies, pattern coverage, browser-friendly Subscriber logs into site App server sets 3 CloudFront-* cookies Cookies sent with every CloudFront request CloudFront-Policy (base64 custom policy) CloudFront-Signature CloudFront-Key-Pair-Id CloudFront validates per request S3 origin subscribers/* Policy covers subscribers/*: HLS manifest + thousands of segments all allowed under one cookie set. Cache keys reuse across subscribers because URL is clean; cookies travel in request headers. Firmware update, signed URL with IP restriction, device-friendly Device GET update metadata Update API returns signed URL GET /firmware/v4.2.bin?Policy=...&Signature=... custom policy: IP in 203.0.113.0/24, expires in 30 min device's HTTP client doesn't need cookies CloudFront validates IP + sig S3 origin firmware/
Signed URLs when the consumer wants a self-contained link for one file; signed cookies when the session needs to cover many files with clean URLs.

The picks in depth

E-book → signed URL with canned policy. When the purchase completes, the order service generates a CloudFront signed URL for https://d123.cloudfront.net/books/<order-id>.pdf with an Expires 10 minutes in the future and the canned-policy signature calculated from the private key. Email delivers the link; customer clicks; CloudFront validates the signature and the expiry and serves the PDF from the private S3 origin (attached via Origin Access Control, so S3 doesn’t serve unsigned requests).

Code-wise:

const signer = new AWS.CloudFront.Signer(keyPairId, privateKey);
const url = signer.getSignedUrl({
  url: `https://d123.cloudfront.net/books/${orderId}.pdf`,
  expires: Math.floor(Date.now() / 1000) + 600,
});

Two guardrails. First, the Expires should be short enough that a forwarded link is useless within the window the customer actually uses it. Second, S3 has the PDF tagged with Object-Lock or versioned, so even if the link is shared, the window of exposure is small.

Premium video → signed cookies. When a subscriber logs in successfully, the session-initiation endpoint sets three cookies on the CloudFront domain:

  • CloudFront-Policy: base64-encoded custom policy {"Statement":[{"Resource":"https://d123.cloudfront.net/subscribers/*","Condition":{"DateLessThan":{"AWS:EpochTime":1234567890}}}]}.
  • CloudFront-Signature: base64 of the RSA-SHA1 signature of the policy.
  • CloudFront-Key-Pair-Id: the public key ID CloudFront uses to validate.

The cookies have Domain=.cloudfront.net, Path=/, Secure, HttpOnly, and an expiry matching the policy. Every subsequent request to the CloudFront distribution includes them; CloudFront validates and serves from S3 if they match.

Because the policy is wildcard (subscribers/*), the HLS manifest, the segment list, and the thousands of .ts segments for a two-hour film are all authorised under the same cookie set. Without signed cookies this would be thousands of signed URLs, each with a separate cache key in CloudFront, cache hit rates collapse, bill climbs.

The pitfall worth naming: the app must set the cookies on the CloudFront domain, not the app domain, or the browser won’t send them on CloudFront requests. Teams sometimes misconfigure this and spend a day chasing it. A Domain=.example.com with CloudFront as a subdomain of the app works; separate cloudfront.net with a Domain=cloudfront.net works; mixing them breaks.

Firmware → signed URL with custom policy and IP restriction. Devices ask the update API “is there a new firmware for me?” The API responds with a signed URL for the binary. The URL uses a custom policy rather than canned so it can include the device’s expected IP range:

{
  "Statement": [{
    "Resource": "https://d123.cloudfront.net/firmware/v4.2.bin",
    "Condition": {
      "DateLessThan": { "AWS:EpochTime": 1234567890 },
      "IpAddress": { "AWS:SourceIp": "203.0.113.0/24" }
    }
  }]
}

If a URL leaks outside the carrier’s IP range (because someone scraped the API response and tried to download from their laptop), the IP condition fails and CloudFront returns 403. The device, which has no cookie store, downloads the binary with the URL alone.

Key management

Both mechanisms share a key story worth describing once.

  1. Generate a 2048-bit RSA key pair (OpenSSL: openssl genrsa -out private_key.pem 2048).
  2. Upload the public key to CloudFront (aws cloudfront create-public-key), receive a PublicKey ID.
  3. Add the public key to a key group (aws cloudfront create-key-group), distributions reference key groups, not keys directly, which lets you rotate without downtime.
  4. Attach the key group to the distribution’s behaviour that should require signing (TrustedKeyGroups in the cache behaviour).
  5. Store the private key in Secrets Manager or Parameter Store SecureString; give the signing Lambda / app server kms:Decrypt to read it.

Key rotation: generate a new key pair, upload the new public key, add it to the same key group, switch signing to the new private key, remove the old public key from the key group after the longest expected TTL. The distribution continues to honour signatures from either key during the rollover window.

What’s worth remembering

  1. Signed URLs authorise one URL (canned) or a pattern (custom). Good for single files and for consumers that can’t handle cookies.
  2. Signed cookies authorise a pattern of URLs. Good for browsers fetching many resources in a session.
  3. The cryptographic primitive is the same. RSA-SHA1 over a policy document, validated by CloudFront using a public key in a key group.
  4. Canned vs custom policies. Canned: one resource, simple expiry. Custom: pattern, expiry, start date, IP restriction. Cookies only support custom.
  5. Cookie domain must match the CloudFront domain. Subtle bug when the app domain differs; Domain=.example.com if CloudFront is a subdomain, otherwise the browser won’t send.
  6. Cache-key pollution is the URL-signing tax. Each signature is a different query string; unless the distribution strips signature params from the cache key, you cache one object per signer. Signed cookies don’t have this tax.
  7. Origin Access Control locks the S3 origin. Without OAC (or legacy OAI), the S3 bucket must be public for CloudFront to reach it, defeating the signing. OAC signs CloudFront → S3 requests with SigV4.
  8. IP restriction is a custom-policy option. Helpful for server-to-server fetches from known ranges; risky when NAT or CDN egress ranges are large or unpredictable.
  9. Short TTLs are the first line of defence. A 10-minute URL stops being useful well before most misuse. Rotate keys for stronger revocation.
  10. Store private keys in Secrets Manager. The signing app reads on startup or via the extension; CloudTrail records every read; the key never lives in code.

Signed URLs for one-off shareable links and clients that can’t manage cookies; signed cookies for browser sessions fetching many files from a pattern. Same cryptographic backbone, different ergonomics, different cache-key behaviour. The work isn’t picking a favourite, it’s reading the consumer shape and picking the mechanism that matches.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.