CloudFront Signed URLs vs Signed Cookies for Media

Use a signed URL when the protected thing is one object (a download, a single MP4, an image) and signed cookies when it is a tree of objects fetched by a player (an HLS or DASH package with a manifest, init segments and hundreds of media segments); in both cases sign with a key in a CloudFront key group, keep the bucket private behind Origin Access Control, and use a custom policy with a wildcard resource and a short expiry.

Private media — paid courses, a user’s own uploads, content behind a subscription — cannot be served from a public bucket, and it should not be proxied through your application servers either. CloudFront can check a signature at the edge and serve cached bytes to whoever holds it. The choice between URL and cookie decides whether that works with a video player at all. This page belongs to secure media delivery in media processing and delivery pipelines. It is the read-side counterpart of S3 presigned URL workflows, which sign writes.

When to use this approach

  • Media is private per user or per entitlement, but you want CDN caching and edge performance for it.
  • You serve adaptive streams, where one “video” is a manifest that references many other URLs the player fetches on its own.
  • You want revocation measured in minutes, not in cache TTLs, without per-request calls to your origin.

Prerequisites

  1. A CloudFront distribution with the private bucket as origin, using Origin Access Control so the bucket rejects every request that does not come through CloudFront.
  2. An RSA-2048 or ECDSA P-256 key pair; the public key uploaded to CloudFront and added to a key group; the key group set as a trusted key group on the protected cache behaviour.
  3. @aws-sdk/cloudfront-signer v3 in the service that issues access.
  4. For cookies: the distribution on a subdomain of your app’s domain (media.example.com for app.example.com), so the browser sends the cookies.

Why URLs break players

A signed URL carries its signature in the query string, and the signature covers that URL (or a wildcard of it). Sign master.m3u8 and the player loads it — then requests 720p/index.m3u8, 720p/init.mp4 and 720p/seg_000.m4s, which are different URLs with no signature. Every one of them is a 403.

Signed URL versus signed cookies for an HLS package With a signed URL only the master playlist request carries a signature; the variant playlist and segment requests have none and are rejected with 403. With signed cookies the browser attaches the same three cookies to every request under the path, so every request is allowed. One signature, forty requests signed URL master.m3u8?Signature=… 200 720p/index.m3u8 403 720p/init.mp4 403 720p/seg_000.m4s 403 signed cookies master.m3u8 + cookies 200 720p/index.m3u8 + cookies 200 720p/init.mp4 + cookies 200 720p/seg_000.m4s + cookies 200 Cookies ride on every request to the domain automatically; query strings do not propagate to relative URLs.
A player follows relative URLs from the manifest; only a credential that travels by itself — a cookie — reaches them.

You can make signed URLs work for HLS by rewriting every playlist on the fly to append a signature to each URI, but that means an origin function on every playlist request and playlists that can no longer be cached. Cookies avoid all of it.

Implementation

import { getSignedUrl, getSignedCookies } from "@aws-sdk/cloudfront-signer";

const KEY_PAIR_ID = process.env.CF_KEY_ID!;                 // public key ID in the key group
const PRIVATE_KEY = process.env.CF_PRIVATE_KEY_PEM!;        // from a secrets manager, never in code
const MEDIA_ORIGIN = "https://media.example.com";

/** Single object: a download or a progressive MP4. Canned policy, exact URL. */
export function signedDownloadUrl(key: string, ttlSeconds = 300): string {
  return getSignedUrl({
    url: `${MEDIA_ORIGIN}/${key}`,
    keyPairId: KEY_PAIR_ID,
    privateKey: PRIVATE_KEY,
    dateLessThan: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
  });
}

/** A whole package: custom policy with a wildcard over the asset's prefix. */
export function signedPackageCookies(assetPrefix: string, ttlSeconds = 3600): Record<string, string> {
  const policy = JSON.stringify({
    Statement: [{
      Resource: `${MEDIA_ORIGIN}/${assetPrefix}/*`,        // every file under this asset, nothing else
      Condition: {
        DateLessThan: { "AWS:EpochTime": Math.floor(Date.now() / 1000) + ttlSeconds },
      },
    }],
  });
  const cookies = getSignedCookies({ keyPairId: KEY_PAIR_ID, privateKey: PRIVATE_KEY, policy });
  return {
    "CloudFront-Policy": cookies["CloudFront-Policy"]!,
    "CloudFront-Signature": cookies["CloudFront-Signature"]!,
    "CloudFront-Key-Pair-Id": cookies["CloudFront-Key-Pair-Id"]!,
  };
}

/** Express-style handler: authorise, then set cookies scoped to the media domain and path. */
export function grantPlayback(
  res: { setHeader(name: string, value: string[]): void },
  assetId: string,
  version: number,
): string {
  const prefix = `media/${assetId}/v${version}`;
  const cookies = signedPackageCookies(prefix, 3600);
  const attrs = `Domain=.example.com; Path=/${prefix}/; Secure; HttpOnly; SameSite=None; Max-Age=3600`;
  res.setHeader("Set-Cookie", Object.entries(cookies).map(([k, v]) => `${k}=${v}; ${attrs}`));
  return `${MEDIA_ORIGIN}/${prefix}/master.m3u8`;
}

console.log(signedDownloadUrl("downloads/9c1f/original.mov"));
console.log(signedPackageCookies("media/9c1f/v1"));

Line-by-line on the parameters that matter

  • Key groups, not the root account’s CloudFront key pair. Key groups are managed with ordinary IAM, support several active public keys for rotation, and can be attached per cache behaviour. The legacy account-level key pairs require the root user and cannot be rotated without downtime.
  • Canned policy for URLs, custom policy for cookies. A canned policy covers one exact URL and an expiry, and produces a shorter URL. A custom policy allows a wildcard Resource, an optional start time and an IP range — the wildcard is what makes one cookie cover a whole package.
  • Resource scoped to one asset version. media/9c1f/v1/* grants exactly that video. A wildcard like media/* would hand every subscriber every video for an hour.
  • Path=/media/9c1f/v1/ on the cookie. The browser only sends it for requests under that path, which keeps cookie headers small when a page shows many videos, and avoids a stale cookie for one asset shadowing a fresh one for another.
  • SameSite=None; Secure because the player on app.example.com fetches from media.example.com, which the browser treats as a cross-site request for cookie purposes only if the registrable domain differs — here it does not, but third-party embeds of your player would need it. HttpOnly keeps scripts, including injected ones, from reading the cookie.
  • TTL of one hour for cookies, five minutes for URLs. A cookie must outlive the viewing session; the player re-requests segments throughout. A download URL only needs to survive until the download starts — CloudFront checks the signature on the request, not for the duration of the transfer.

Choosing between them

Decision guide for signed URLs or signed cookies If the client fetches a single file, use a signed URL. If it fetches many related files and runs on your own domain, use signed cookies. If it fetches many files from a third-party context where cookies are blocked, rewrite manifests with per-URI signatures or use a token in the path. Which credential? how many files per view? one — download, MP4 signed URL, 5 min many — HLS / DASH can cookies reach it? yes, same site: signed cookies wildcard over the version no: embeds token in path
Third-party embeds lose cookies to browser privacy rules, which is the one case where streaming needs a URL-borne token after all.

For third-party embeds, put a short-lived token in a path segment (/t/<token>/media/9c1f/v1/master.m3u8) and validate it in a CloudFront Function, which then strips it before the cache lookup. Relative URLs in the manifest inherit the token segment automatically, because they resolve against the manifest’s path.

Configuration gotchas

<Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message>. The request reached a behaviour that requires signing, but no credentials arrived. For cookies, check Domain (it must cover the media hostname) and Path (it must be a prefix of the request path); DevTools → Application → Cookies shows whether the browser stored them.

<Code>AccessDenied</Code> with valid-looking cookies. Most often the policy’s Resource uses http:// while the request is https://, or the resource omits the trailing /*. The resource string must match the full request URL including scheme and host.

Cached 403s after granting access. Error caching is on for 403 with a default TTL. A player that probed before the cookie was set gets the cached error for minutes. Set error caching minimum TTL for 403 to 0 on the protected behaviour.

Signatures fail after a clock change. DateLessThan is compared against CloudFront’s clock. A signing service with a drifting clock issues credentials that are already expired. Run NTP on signers and add a minute of margin to short TTLs.

Refreshing cookies during long playback

A one-hour cookie is fine for a ten-minute clip and a problem for a two-hour lecture: the player keeps fetching segments after the cookie expires and hits a 403 in the middle of playback. Shortening TTLs for faster revocation makes this worse. The answer is to refresh credentials while the video plays, rather than issuing one long-lived credential up front.

Give the page a small endpoint, POST /media/:assetId/grant, that re-checks the viewer’s entitlement and sets fresh cookies with the same scope. Call it on a timer at roughly half the TTL while the player is active, and once more on the player’s error event if the error is a 403, before retrying the failed segment. Because the cookies are HttpOnly, the refresh is invisible to scripts and the player simply keeps working. If the entitlement check fails — the subscription lapsed, the account was suspended — the endpoint returns 403 and the page can stop playback with a clear message instead of a cryptic network error.

This keeps cookie TTLs short (ten to fifteen minutes is common) without ever interrupting a legitimate viewer.

Revocation and rotation

How long access lasts after it should end A signed credential stays valid until its expiry, so revoking a subscription at minute 10 of a one-hour cookie leaves up to 50 minutes of residual access. Shortening cookie TTL to 10 minutes and refreshing them from the player cuts residual access to under 10 minutes. Residual access after revocation at minute 10 60-min cookie 50 min still valid 10-min, refreshed ≤ 9 min revoked Short TTLs plus a refresh endpoint the player calls every few minutes give revocation in minutes, with no edge-side deny list. Rotate signing keys by adding a new key to the group before removing the old.
Signed credentials cannot be recalled, only outlived; the TTL is your revocation latency.

Verification

# 1. Unsigned request to the protected path is refused at the edge, not by S3.
curl -s -o /dev/null -w '%{http_code} %header{x-cache}\n' https://media.example.com/media/9c1f/v1/master.m3u8
# 403 Error from cloudfront

# 2. Direct bucket access is refused (OAC working).
curl -s -o /dev/null -w '%{http_code}\n' https://my-private-bucket.s3.amazonaws.com/media/9c1f/v1/master.m3u8
# 403

# 3. With cookies, a segment deep in the package is served.
curl -s -o /dev/null -w '%{http_code}\n' \
  -b "CloudFront-Policy=$P; CloudFront-Signature=$S; CloudFront-Key-Pair-Id=$K" \
  https://media.example.com/media/9c1f/v1/720p/seg_017.m4s
# 200

# 4. The same cookies do not open a different asset.
curl -s -o /dev/null -w '%{http_code}\n' -b "CloudFront-Policy=$P; CloudFront-Signature=$S; CloudFront-Key-Pair-Id=$K" \
  https://media.example.com/media/aa77/v1/master.m3u8
# 403

Frequently Asked Questions

Can I use S3 presigned URLs instead of CloudFront signing?

For occasional downloads, yes — but every request then goes to S3 directly, uncached, and a presigned URL covers exactly one object, so the HLS problem is the same. CloudFront signing adds caching and wildcard policies, and it lets the bucket stay completely private behind OAC.

Do signed cookies work in Safari’s native HLS player?

Yes. Native playback uses the browser’s network stack and sends cookies for the media domain like any other request, provided the cookie’s Domain and Path match. It is hls.js on other browsers that additionally needs CORS with Access-Control-Allow-Credentials: true and xhrSetup setting withCredentials.

How is this different from DRM?

Signing controls who can download the bytes; DRM controls what can be done with them after download. Signed cookies stop link sharing and hotlinking; they do not stop a subscriber recording the stream. For studio content with DRM requirements, add encryption at packaging time on top of signing.