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
- 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.
- 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.
@aws-sdk/cloudfront-signerv3 in the service that issues access.- For cookies: the distribution on a subdomain of your app’s domain (
media.example.comforapp.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.
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. Resourcescoped to one asset version.media/9c1f/v1/*grants exactly that video. A wildcard likemedia/*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; Securebecause the player onapp.example.comfetches frommedia.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.HttpOnlykeeps 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
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
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.