Secure Media Delivery

The last hop of a media pipeline has two jobs that pull in opposite directions: serve bytes from as close to the viewer as possible, as few times from origin as possible — and make sure only the right viewers, on the right pages, for the right length of time, get them. Most delivery incidents are one of those jobs undermining the other: a private document cached publicly at the edge, a paid video whose signed link circulates for a week, a viral image whose embeds on someone else’s site triple the egress bill.

This topic belongs to media processing and delivery pipelines. It serves what the processing topics produce — packages from adaptive bitrate video streaming, variants from responsive image delivery, encodes from audio processing pipelines — and it mirrors, on the read side, the access model that S3 presigned URL workflows set up for writes.

Prerequisites

  • [ ] A dedicated media hostname (for example media.example.com) on a subdomain of your app’s domain.
  • [ ] Buckets with public access blocked, reachable only through the CDN (CloudFront Origin Access Control, Cloud CDN backend buckets, or Front Door private link).
  • [ ] A signing key managed outside code: a CloudFront key group key in a secrets manager, a GCS service account with keyless signing, or an Azure managed identity.
  • [ ] A versioned object-key scheme for every derived file, so caches can hold them for a year.
  • [ ] An edge runtime (Workers, CloudFront Functions, Lambda@Edge) for referrer checks and token validation.
  • [ ] Access logs from both the CDN and the storage service, retained long enough to investigate a leak.

How it works

Every media request passes through up to four gates, and each one answers a different question.

Can this request reach storage at all? Only through the CDN. The bucket refuses anonymous reads and trusts one identity — the CDN’s origin access identity or control. This single setting prevents the most common leak: a direct bucket URL shared or indexed, bypassing every other check.

Is this viewer entitled to this object? For private media, a signature answers it: a signed URL for a single object, signed cookies for a package of many files, both scoped to exactly one asset version and valid for minutes. CloudFront signed URLs vs signed cookies for media covers AWS; serving private GCS and Azure media with signed URLs covers the other two major clouds.

Is this page allowed to show it? For public media, the question is not who but where. Fetch metadata and referrer checks at the edge decide whether an embed is yours or someone else’s, per preventing hotlinking of uploaded media.

How long may caches keep it? Cache-Control decides, set once when the object is written: immutable for versioned files, short for manifests and aliases, private for per-user media. Setting Cache-Control headers for uploaded media gives the table.

Underneath all four, the transport must behave: range requests for progressive video, correct content types, and no middleware that silently rewrites bodies, as serving video with HTTP range requests explains.

Four gates between a media request and storage A browser request passes through the edge, which checks referrer and fetch metadata for public media, validates a signature for private media, and applies cache rules. Only cache misses reach the private bucket, which accepts requests only from the CDN's origin identity. Browser → edge gates → cache → private origin browser img, video, fetch CDN edge where? (public) Sec-Fetch-Site, Referer who? (private) signature, expiry, scope how long? cache by Cache-Control immutable / short / private hits end here — 90%+ of bytes bucket CDN identity only miss A direct bucket URL must fail — otherwise every gate at the edge is optional.
Checks happen at the edge so the cache can still serve the bytes; the origin only ever talks to the CDN.

Step-by-step implementation

Step 1: Close the bucket to everyone but the CDN

Block public access at the account and bucket level, then grant read to the CDN’s origin identity only. On AWS that is a bucket policy trusting the CloudFront service principal for one distribution:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontOAC",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::media-private/*",
    "Condition": {
      "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E2QWRUHAPOMQZL" }
    }
  }]
}

The AWS:SourceArn condition matters: without it, any CloudFront distribution in any account could be pointed at your bucket.

Step 2: Split public and private media by path

Put public media (profile photos, public posts) and private media (paid content, personal files) under different path prefixes with different CDN behaviours. Public paths get referrer checks and long caching; private paths require signatures and never share cache entries across users.

export type Exposure = "public" | "private";

export function mediaKey(exposure: Exposure, assetId: string, version: number, file: string): string {
  if (!/^[0-9a-f-]{8,64}$/.test(assetId)) throw new Error("invalid asset id");
  if (file.includes("..") || file.startsWith("/")) throw new Error("invalid file path");
  return `${exposure === "public" ? "pub" : "priv"}/${assetId}/v${version}/${file}`;
}

console.log(mediaKey("private", "9c1f2a7e-44b0", 3, "720p/seg_004.m4s"));
// priv/9c1f2a7e-44b0/v3/720p/seg_004.m4s

Changing an asset from public to private is then a copy to a new prefix and a pointer update — never a permission change on objects that caches may already hold.

Step 3: Issue short-lived access for private media

After your application authorises the viewer, it issues a credential scoped to exactly one asset version: signed cookies for packages, a signed URL for single files. The TTL is the revocation latency.

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

export function grantPackage(assetId: string, version: number, ttlSeconds = 900): string[] {
  const resource = `https://media.example.com/priv/${assetId}/v${version}/*`;
  const policy = JSON.stringify({ Statement: [{ Resource: resource,
    Condition: { DateLessThan: { "AWS:EpochTime": Math.floor(Date.now() / 1000) + ttlSeconds } } }] });
  const c = getSignedCookies({
    keyPairId: process.env.CF_KEY_ID!, privateKey: process.env.CF_PRIVATE_KEY_PEM!, policy,
  });
  const attrs = `Domain=.example.com; Path=/priv/${assetId}/v${version}/; Secure; HttpOnly; SameSite=Lax; Max-Age=${ttlSeconds}`;
  return [
    `CloudFront-Policy=${c["CloudFront-Policy"]}; ${attrs}`,
    `CloudFront-Signature=${c["CloudFront-Signature"]}; ${attrs}`,
    `CloudFront-Key-Pair-Id=${c["CloudFront-Key-Pair-Id"]}; ${attrs}`,
  ];
}

console.log(grantPackage("9c1f2a7e-44b0", 3).length);
// 3

Refresh the grant from the page at half its TTL while playback continues, so long videos never hit an expired cookie.

Step 4: Guard public media against embedding elsewhere

For the public prefix, a small edge function classifies each request by Sec-Fetch-Site and Referer, serves a placeholder to cross-site image embeds, refuses cross-site video, and adds Cross-Origin-Resource-Policy.

export function hotlinkVerdict(h: Headers, ownSite = "example.com"): "allow" | "placeholder" | "deny" {
  const site = h.get("Sec-Fetch-Site");
  if (site === "same-origin" || site === "same-site" || site === "none") return "allow";
  const ref = h.get("Referer");
  if (ref) {
    try { if (new URL(ref).hostname.endsWith(ownSite)) return "allow"; } catch { /* malformed */ }
  }
  if (site === "cross-site") return h.get("Sec-Fetch-Dest") === "image" ? "placeholder" : "deny";
  return "allow";                       // no metadata and no Referer: benefit of the doubt
}

console.log(hotlinkVerdict(new Headers({ "Sec-Fetch-Site": "cross-site", "Sec-Fetch-Dest": "video" })));
// deny

Step 5: Write cache headers with the object

Every writer — the processing worker, the publish step, any backfill — calls one policy function, so headers never depend on who wrote the file.

export function cacheControl(key: string): string {
  if (key.startsWith("priv/")) return key.endsWith(".m3u8") || key.endsWith(".mpd")
    ? "private, max-age=60" : "private, max-age=3600";
  if (key.endsWith(".m3u8") || key.endsWith(".mpd")) return "public, max-age=300, s-maxage=3600";
  if (/\/v\d+\//.test(key)) return "public, max-age=31536000, immutable";
  return "public, max-age=60, stale-while-revalidate=600";
}

for (const k of ["pub/9c1f/v3/720p/seg_004.m4s", "pub/9c1f/v3/master.m3u8", "priv/9c1f/v3/doc.pdf"]) {
  console.log(k.padEnd(32), cacheControl(k));
}
// pub/9c1f/v3/720p/seg_004.m4s     public, max-age=31536000, immutable
// pub/9c1f/v3/master.m3u8          public, max-age=300, s-maxage=3600
// priv/9c1f/v3/doc.pdf             private, max-age=3600
Public and private request paths compared A public image request is checked for referrer, served from shared cache, and cached for a year. A private video segment request is checked for a signed cookie scoped to one asset version, served from a cache entry that requires the signature, and cached privately in the browser only. Two prefixes, two rule sets pub/… profile photo 1. referrer / fetch-metadata check 2. shared edge cache, 1 year 3. browser cache, immutable 4. CORP: same-site cost per view ≈ zero after first priv/… course video 1. signed cookie for this version 2. edge cache keyed with the grant 3. browser cache: private only 4. refresh grant every 7 min revocation within one TTL
Public media optimises for reach and caching; private media gives up a little caching for scoped, expiring access.

Configuration reference

Setting Type Default here Effect
Bucket public access toggle blocked Direct bucket URLs fail; all reads go through the CDN.
Origin identity IAM condition one distribution ARN Stops other CDN configurations reading your bucket.
Private grant TTL seconds 900 Revocation latency; refresh at half this while playing.
Grant scope resource priv/<asset>/v<n>/* One asset version per credential, never a whole prefix.
Signed URL TTL (downloads) seconds 120–300 Only needs to survive until the download starts.
Hotlink policy edge rule placeholder images, 403 video Stops foreign embeds from spending your egress.
CORP header header same-site Browser-side refusal of cross-site embeds.
Versioned object Cache-Control header public, max-age=31536000, immutable Year-long caching for files that never change.
Manifest Cache-Control header public, max-age=300, s-maxage=3600 Short in browsers, longer at the edge.
Private Cache-Control header private, max-age=3600 Browser-only caching for per-user media.
Error caching for 403 TTL 0 A pre-grant 403 is never served after the grant exists.

Edge cases and gotchas

Cached 403s

CDNs cache error responses by default. A player that probed a private manifest before its cookies were set receives a 403 that the edge then serves to the same user for minutes after access was granted. Set the error-caching TTL for 403 and 404 on protected paths to zero.

Cross-origin players and credentials

hls.js fetches with XHR; to send cookies cross-origin it needs xhrSetup: (xhr) => { xhr.withCredentials = true; }, and the media response needs Access-Control-Allow-Credentials: true plus an explicit Access-Control-Allow-Origin (not *). Native Safari playback needs neither, which hides the misconfiguration during iPhone testing.

Shared caches and Vary

Any response that differs by request header — format negotiation on Accept, hotlink verdicts on Referer — must say so with Vary or be cached under a key that includes the deciding value. Otherwise the first variant cached is served to everyone.

A signed URL in a chat message works for anyone until it expires. Keep TTLs short, share links to your own endpoint that re-signs on click, and log every issued signature with the user who requested it so a leak can be traced.

Geo-restriction for licensed content

Some media may only be shown in certain countries. Enforce it at the edge on the private path — CloudFront and Cloud CDN both expose the viewer’s country — and bake the allowed countries into the grant as well, so a credential issued in one country cannot be replayed from another. Return a distinct status or error body for geo-blocks, so the player can say “not available in your region” rather than “something went wrong”.

Deletion and the right to erasure

Deleting an object does not delete cached copies. Purge the exact URLs from the CDN when a user deletes private media, and remember that private responses may still sit in that user’s own browser cache — which is acceptable, because only they could see it. Public media deleted for legal reasons needs a purge of every variant URL, which is easy only if variants live under one versioned prefix per asset.

Common delivery leaks and the control that stops each A direct bucket URL is stopped by blocking public access and using origin access control. A shared signed link is limited by short TTLs and re-signing endpoints. A private file in shared cache is prevented by Cache-Control private and a signature-aware cache key. A foreign embed is stopped by referrer checks and CORP. Leak → control direct bucket URL shared block public access + OAC signed link forwarded short TTL + re-signing endpoint private file in shared cache Cache-Control private + keyed cache embedded on another site fetch-metadata check + CORP
Each leak has a specific, cheap control; none of them requires proxying media through application servers.

Designing for both speed and control

It is tempting to solve access control by routing every media byte through your application: check the session, stream from storage, done. It works at small scale and fails at every other one — application servers become bandwidth appliances, latency doubles because nothing is cached near the viewer, and every seek in a video becomes an application request. The design on this page keeps the application in the loop only for decisions (issuing a grant, re-signing a link, recording who asked) and leaves the bytes to the CDN.

The price is a little complexity at the edge — a function for referrer checks and tokens, a cache policy that respects private, a key group to rotate — and a discipline in how objects are named and written. That discipline pays off everywhere else: versioned keys give you instant updates and year-long caching, per-version grants give you precise scope, and header policy set at write time means no request-time logic is needed for ninety-something percent of traffic.

When you evaluate a new delivery requirement — DRM for a studio partner, geo-restriction for licensing, watermarking for leak tracing — ask which gate it belongs to. Geo-restriction is an edge rule on the private path. Forensic watermarking belongs in processing, producing per-viewer variants or segment-level A/B marks. DRM is packaging-time encryption plus a licence server, layered on top of, not instead of, signed access. Keeping each concern at its gate is what stops delivery from becoming a tangle of special cases.

The same design on each cloud

The gates are identical everywhere; only the product names change. Use this mapping when a team runs media on more than one provider, or when moving between them.

Gate AWS Google Cloud Azure Cloudflare
Private origin S3 + Origin Access Control GCS backend bucket, private Blob with private endpoint R2 bucket, no public domain
Edge cache CloudFront Cloud CDN Front Door Cloudflare CDN
Signed single file CloudFront signed URL Cloud CDN signed URL Front Door token auth / SAS Worker-validated token
Signed package CloudFront signed cookies Cloud CDN signed cookies Front Door token in path Worker-validated cookie
Edge logic CloudFront Functions, Lambda@Edge Service Extensions Rules engine Workers
Keyless signing Key group key in Secrets Manager IAM signBlob User delegation key Worker secret

Two differences are worth knowing before you rely on the table. Signed cookies are native on CloudFront and Cloud CDN, but on Azure Front Door and Cloudflare they are something your edge code validates — the behaviour is the same, the implementation is yours. And storage-level signatures (S3 presigned URLs, GCS signed URLs, SAS) always bypass the CDN, because every signed storage URL is unique; use them for single downloads, not for anything viewed repeatedly.

Monitoring delivery

Delivery problems show up first in numbers, not tickets. Four are worth a dashboard.

Edge hit ratio by path prefix. Public versioned media should sit above 95%; manifests and aliases lower by design. A drop on the public prefix usually means a cache key started including something it should not — a query string from a new analytics script, an unnormalised header.

403 rate on private paths. A steady low rate is normal (expired grants being refreshed). A spike means grants are not being issued or refreshed, or the key group changed. Break it down by the CDN’s error detail — missing key, expired, wrong signature — because each has a different cause.

Egress by referring site. From the hotlink verdict logs, the top referring origins for your public media. New entries with large volumes are either a partner integration nobody told you about or the start of a hotlinking problem.

Origin requests per asset. For immutable media this should approach one per edge location per asset. An asset with thousands of origin requests is being served with the wrong headers, or its URL is not actually stable.

These four numbers catch nearly every delivery regression within an hour of the change that caused it, which is much sooner than an invoice will.

Verification

# Direct bucket access is refused.
curl -s -o /dev/null -w '%{http_code}\n' https://media-private.s3.amazonaws.com/pub/9c1f/v3/256.webp
# 403

# Public media through the CDN is cached for a year and carries CORP.
curl -sI https://media.example.com/pub/9c1f/v3/256.webp | grep -iE 'cache-control|cross-origin-resource-policy|x-cache'

# Private media without a grant is refused at the edge, and the refusal is not cached.
curl -s -o /dev/null -w '%{http_code}\n' https://media.example.com/priv/9c1f/v3/master.m3u8
# 403

# A cross-site video embed is refused; a cross-site image embed gets the placeholder.
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H 'Sec-Fetch-Site: cross-site' \
  -H 'Sec-Fetch-Dest: image' https://media.example.com/pub/9c1f/v3/256.webp

Frequently Asked Questions

Do I need a CDN if my media is private?

Yes, arguably more so. Private media is still viewed repeatedly by its owner and by entitled users; a CDN with signed access serves those repeats from the edge while the signature keeps others out. Without one, every view is an origin read, and origin-side access checks tend to end up in application code that was never meant to stream video.

Signed URLs or tokens in the path for hotlink protection?

For public media, neither by default — fetch-metadata and referrer checks keep URLs stable and cacheable. Use path tokens only when copies must stop working after a time, such as pre-release or paid downloads, and round expiries so tokenised URLs stay cacheable.

How do I rotate signing keys without breaking playback?

Add the new public key to the key group first, deploy signers using the new private key, wait for the longest grant TTL to pass, then remove the old public key. Grants signed with either key validate during the overlap, so no viewer sees a gap.