A complete implementation guide for HLS and DASH streaming from key pair to player config to the cross-domain limit that breaks cookie auth.
A CloudFront distribution in front of an S3 bucket or a packaging origin is, by default, an open library. Nothing about a long random object key makes it private. The first person to copy a manifest URL out of DevTools can paste it into a group chat, and it will keep working indefinitely. If you sell the content, that is a revenue problem. If the content is private coaching footage, medical training, or internal comms, it is a disclosure problem.
CloudFront's answer is to make the edge verify a signature before it serves anything. You sign a policy with a private key; CloudFront checks it with the matching public key and returns 403 if anything is wrong. This is edge access control, not DRM. It decides who receives the bytes and does nothing to stop someone who already received them from redistributing them.
This guide walks the whole path, including key pair, distribution config, token generation, cookie attributes, player configuration, and verification. It then covers the constraint that ends up dominating the design on multi-tenant platforms: signed cookies are same-site cookies.
Four participants: the player, your application backend, the CloudFront edge, and the origin. The private key never leaves your backend, and CloudFront only ever holds the public half. Signing is a local RSA or ECDSA operation with no call out to AWS, so issuing a token costs nothing but CPU and can happen inside the entitlement endpoint you already have.
Playback Authorization Flow
CloudFront accepts RSA 2048 or ECDSA P-256 keys for signing, and nothing else (AWS: Restrict access to files).
Upload cf-public.pem to CloudFront (Key management, Public keys). The public key ID it returns is the value you will send as Key-Pair-Id. Put the private key in Secrets Manager or Parameter Store, it is a credential that mints access to your entire library.
Add the public key to a key group, then attach the key group to a cache behavior under Restrict viewer access. AWS recommends key groups over the legacy account-level CloudFront key pairs, which require root-user access to manage (AWS: Specify signers). The attachment is per cache behavior, not per distribution, so you can require signatures on /vod/* while poster images under /thumbnails/* stay open. A key group holds several keys at once, which is how you rotate: add the new public key, start signing with the new private key, and remove the old one only after every in-flight token has expired.
Lock the origin down with origin access control at the same time. If the S3 bucket is publicly readable, everything above is theatre. Anyone who finds the bucket URL bypasses the edge check entirely.
This step applies to the signed-cookie flow, because the player fetches manifests and segments cross-origin with credentials attached. Attach a response headers policy to the video cache behavior with:
If several client origins share one distribution, add the Origin header to the cache key. Otherwise, the first requester's Access-Control-Allow-Origin value gets cached and replayed to everyone else, which fails in a way that looks intermittent and unreproducible.
Do not add the CloudFront-* cookies to the cache policy. CloudFront validates them at the edge and strips them before forwarding to the origin; including them in the cache key would make every viewer's request a cache miss and turn your CDN into an expensive proxy.
If your unsigned URLs already carry Expires, Policy, Signature, or Key-Pair-Id as query parameters, neither mechanism will work. CloudFront assumes any URL containing them is a signed URL and stops looking at cookies entirely (AWS: Decide to use signed URLs or signed cookies). Those four names are reserved for your own query strings too.
A CloudFront policy is a single statement naming a resource and the conditions under which it is valid. A canned policy covers one exact resource with an expiry and nothing else. A custom policy is what streaming needs: it accepts a wildcard resource, an optional start time, and an optional source IP restriction.
Exactly one statement is permitted, parameter names are case-sensitive and cannot be abbreviated, and all whitespace must be stripped before signing (AWS: custom policy for signed cookies). The wildcard is not a convenience: one grant has to cover the master manifest, every rendition sub-manifest, and several thousand segments.
Pass policy explicitly. If you use the convenience parameters instead dateLessThan on its own the signer emits a canned policy and the cookies come back as CloudFront-Expires rather than CloudFront-Policy. That mismatch has generated enough confusion to produce its own SDK issues; the package README shows the policy-string form for both helpers.
This deserves its own warning because the failure mode is invisible in testing. AWS:SourceIp takes IPv4 CIDR only; IPv6 addresses are not supported, and AWS's guidance is blunt: if your custom policy includes IpAddress, do not enable IPv6 on the distribution (AWS: Enable IPv6). If you leave IPv6 on, every viewer whose ISP hands them an IPv6 address gets a 403 and CloudFront returns exactly that for a signed request arriving from an IPv6 address (AWS re:Post: troubleshoot signed URLs and cookies). Your office is probably IPv4, so this reaches production intact and then looks like a random subset of users on certain mobile networks.
If you need IP restriction on some content and IPv6 support elsewhere, AWS's own recommendation is two distributions. Two further practicalities: the address you sign must be the viewer's public IP as CloudFront sees it, so read it from the forwarded-for chain rather than the socket if your API sits behind a load balancer; and mobile viewers change IP when they move between cellular and Wi-Fi, which kills a /32-scoped token mid-playback. Most teams either widen the mask or drop the condition and rely on short expiry instead.
Think minutes, not days. Fifteen minutes with silent re-issue during playback is a reasonable default. CloudFront evaluates the policy on every request, so an expired token does not wait politely for the current title to finish. the next segment request returns 403 and playback stalls. Long-form content therefore needs a refresh path: re-call the playback endpoint on a timer at roughly half the TTL, or on the player's error event, and replace the cookies before the old ones lapse.
This is where most implementations go wrong, so it is worth being precise about what each attribute does. Here is the corrected version, followed by the reasoning:
Domain. It must be a parent of the video hostname, not your API hostname. The browser needs to send these cookies to CloudFront, not back to you. And you can only set it for a domain you are already on: an API at api.acme.io cannot set a cookie scoped to video.example.com. The browser will silently drop the header. If the two are on different registrable domains, this whole approach is unavailable, which is the subject of the cross-domain section below.
Path. Cookie paths are literal prefixes; there is no wildcard syntax. Writing /vod/asset-42/* does not scope the cookie, it prevents it from ever being sent, and the symptom is a 403 complaining that the key pair ID is missing. Scoping to the asset prefix is still worth doing: it limits the blast radius of a leaked cookie and lets one browser hold several grants at once.
Max-Age. The original snippet omitted this, which makes them session cookies. They then outlive the policy inside them, so the browser keeps cheerfully attaching a dead token and every request 403s until the tab is closed. Matching maxAge to the policy TTL makes the cookie disappear when it stops being useful.
SameSite. Use lax when the app and the video hostname share a registrable domain. Subresource requests between same-site hosts still carry Lax cookies, and Lax is the safer default. none is only required when the request is genuinely cross-site, and it demands secure: true alongside it. Setting none reflexively does not make cross-site work; it just opts you into the third-party cookie rules described later.
HttpOnly and Secure. Both correct as written. No client code ever needs to read these values, and httpOnly keeps them out of reach of anything that manages to inject script. secure is mandatory for SameSite=None and correct regardless.
Value encoding. Express URL-encodes cookie values by default. That is harmless here, because CloudFront signatures use a URL-safe base64 alphabet the plus, equals, and slash characters are replaced with hyphen, underscore, and tilde before you ever see them, and none of those are touched by encodeURIComponent. If you ever swap in a signer that emits standard base64, pass an identity encode function or the signature will arrive corrupted.
One more: do not rename the cookies. The three names the signer returns are the three names CloudFront looks for.
The fetch that retrieves playback info must opt into credentials, or the browser discards the Set-Cookie headers entirely:
The browser will then reattach the cookies to the manifest, sub-manifests, and segments on its own but only if the player asks for credentialed requests, which none of them do by default. Each library exposes this differently.
Set both callbacks: xhrSetup covers the XHR loader, fetchSetup covers the fetch loader, and which one runs depends on configuration and platform (hls.js API docs).
Credentials are a VHS option, settable at initialisation or per source (videojs/http-streaming README). When it is on, every manifest and segment XHR carries credentials which is exactly why the wildcard origin stops working.
Shaka uses a request filter on the networking engine. Register it before calling load(), and note that it is deliberately off by default: sending credentials to an endpoint that does not explicitly allow them makes the request fail even when there are no cookies to send (Shaka: license server authentication).
Native Safari playback is the exception to all of this. When <video src="...m3u8"> is handled by the platform rather than by MSE, you have no loader hooks at all, the cookies either qualify as first-party and are sent, or they are not, and playback fails. There is no configuration flag to reach for.
Signed URLs look simpler, but they are not. You sign the master manifest, the player fetches it successfully, then parses it and requests 1080p/index.m3u8 a URI written inside the manifest, carrying no signature. 403. Every inner URI needs the same query string, which means something has to rewrite manifests between the origin and the player: a CloudFront Function, a Lambda@Edge, or a small proxy.
That covers the common case; a production rewriter has more to do. HLS also hides URIs inside tag attributes #EXT-X-KEY, #EXT-X-MAP, #EXT-X-MEDIA, #EXT-X-I-FRAME-STREAM-INF and DASH puts them in BaseURL elements and SegmentTemplate attributes.
Two rules that save a day of debugging. First, the signature covers the resource including its query string, so nothing may be appended after signing, a cache-busting parameter or an analytics tag added by the player turns a valid URL into an Access Denied (AWS re:Post). Anything you need in the URL has to be there before you sign it. Second, cache the unsigned manifest and inject the signature per response; if the signed manifest itself becomes cacheable per viewer, your hit rate collapses.
Before wiring the player, prove the edge behaves. Two curl calls tell you almost everything:
If the second call fails, drop the -o /dev/null and read the body: CloudFront returns an XML error naming the specific problem, such as a missing key pair ID value. The AWS troubleshooting guide maps each message to a cause. In practice, the 403s cluster into six: the public key is not in the key group attached to that behavior; whitespace survived in the policy JSON; the cookie Path does not prefix the request path; the Domain does not cover the video hostname; the token has expired; or the viewer arrived over IPv6 while the policy pins IPv4.
Everything above works flawlessly in local development and then breaks in production for a subset of users, because of one structural fact: the cookie has to reach the CloudFront hostname. If your application runs on app.customer.com and video is served from video.yourplatform.com, that cookie is third-party, and whether it survives is the browser's decision, not yours.
Browsers have been converging on "no" for years, at different speeds. As of mid-2026:
So "it works in Chrome" means "it works for most Chrome users today," which is a different claim. Roughly a fifth of global traffic is cookieless by default independent of anything Chrome does, plus the Chrome users who have opted out and every private-browsing session. Treating this as a Safari quirk to be worked around is the most expensive misdiagnosis in this problem space.
The useful detail is that the boundary is the registrable domain, not the exact hostname. video.example.com and app.example.com are the same site, so the cookie is first-party and none of the above applies. Where you control both ends, the fix is simply to serve video from a hostname under the application's domain, using an alternate domain name on the distribution and a matching ACM certificate in us-east-1.
The hard version of this problem is a platform whose clients each run their own domain. Four strategies, with honest tradeoffs:
Per-tenant CNAME. Alias video.customer-a.com to your distribution and add it as an alternate domain name with its own certificate. The cookie becomes first-party and the problem disappears. The cost is operational: DNS delegation and certificate validation per tenant, and an onboarding step that blocks playback until someone in the customer's IT team creates a record. Workable at tens of tenants, painful at thousands, and bounded by the per-distribution limit on alternate domain names.
Signed URLs plus the rewriting proxy. The domain-agnostic fallback. Nothing depends on cookie policy because there are no cookies, and it behaves identically in every browser and embedded webview. You pay in infrastructure on the playback path rather than in onboarding friction.
Hybrid. Cookies for tenants whose video hostname sits under their own domain, signed URLs for everyone else. This is where most mature platforms land. Drive it from a server-side per-tenant capability flag decided at provisioning time never from user-agent sniffing, which will be wrong for embedded webviews and for every browser released after you wrote the check.
CHIPS. Adding the Partitioned attribute opts the cookie into a per-top-level-site jar, which keeps it working in Chrome for users who have disabled third-party cookies and in Incognito (MDN: Partitioned cookies). It does nothing for Safari or Brave, which block third-party cookies regardless of attributes. Partitioning also means a grant issued under one top-level site is invisible under another, which rules out reusing one cookie set across tenant sites. Ship it as hardening, never as the strategy.
Native clients are not subject to any of this. iOS and tvOS accept an HTTP cookie option when constructing the asset, and Android players take request headers on the data source factory, so cookies work fully cross-domain there. The tradeoff is manual cookie handling per platform SDK, including refresh during long playback. It is also the answer to the inevitable question of why the mobile app works and the web app does not.
|
Signed cookies |
Signed URLs |
|
|---|---|---|
|
Grant scope |
One grant covers an entire path prefix |
Client must carry params on every request |
|
URL shape |
Unchanged |
Signature appended as query params |
|
Manifest handling |
Nothing extra |
Rewriting proxy required |
|
Cross-domain web |
Constrained by third-party cookie policy |
Unaffected |
|
Extra infrastructure |
None beyond the signing endpoint |
Proxy on the playback path |
|
Debuggability |
Signature invisible in the URL |
Self-contained; reproducible with curl |
|
Leak profile |
Bound to the browser's cookie jar |
Copy-pasteable until expiry |
|
Client support |
Needs a cookie-capable client |
Any HTTP client |
AWS's own guidance is narrower than the decision you actually face: use signed URLs to restrict individual files or to support clients that cannot handle cookies, and signed cookies to grant access to many restricted files HLS is the example they give or when you cannot change existing URLs. If both are present on a request, the signed URL wins and the cookies are ignored (AWS: Decide to use signed URLs or signed cookies).
In practice, the domain topology decides it. If your player and your video hostname share a registrable domain, use signed cookies: fewer moving parts, no proxy, no manifest rewriting. If you serve customer-owned domains and cannot get DNS control, use signed URLs and budget for the proxy. If you have both kinds of tenant, build the hybrid and flag it per tenant. If you are native-only, use cookies and ignore this entire category of problem.
One closing caveat about what you have bought. Signed cookies and signed URLs control access to the bytes. An authorized subscriber can still capture the decrypted stream and republish it, and no expiry window changes that. Above a certain content value first-run film, live sport, anything with a contractual security requirement the answer is DRM through a packaging origin, with Widevine, FairPlay or PlayReady handling encryption and license exchange. Edge access control does not disappear in that architecture; it protects the manifest and license endpoints in front of it. It is a layer, not a substitute.