Generating CloudFront Signed URLs for Private Media

Your masters, member-only renditions and pre-release cuts sit in S3, and you need URLs that a plain <img> or <video> element can load without an SDK, that stop working after a fixed number of minutes, and that the edge rejects on its own without calling your application. CloudFront signed URLs do exactly that, and they fail closed in a way that gives you almost nothing to debug: one wrong byte anywhere in the chain produces the same opaque 403 Forbidden. This page walks the whole chain — key pair, key group, cache behavior, origin lock, signer — as the concrete implementation of the concepts in Signed URLs and Media Access Control, inside the wider CDN & Edge Media Delivery section.

The scope here is the canned policy: one exact URL, one expiry, three query parameters. It is the cheapest signature to generate, the easiest to cache correctly, and the right default for individual media objects. Wildcards, IP conditions and start times need a custom policy, which the parent guide covers.

Prerequisite checklist

What is actually in a signed URL

A canned-policy signed URL is the object URL plus three reserved query parameters. CloudFront strips all three from the cache key automatically, which is why signing does not shatter your edge hit ratio the way a hand-rolled ?token= scheme does.

Anatomy of a canned-policy signed URL A signed URL is drawn as four highlighted segments. The first is the resource URL, which the policy covers byte for byte. The second is Expires, an epoch UTC second count from which CloudFront synthesizes the policy JSON. The third is Signature, an RSA-SHA1 proof over that JSON encoded with a non-standard base64 alphabet. The fourth is Key-Pair-Id, the identifier of the public key inside the trusted key group. https://media.example.com/private/hero.mp4 ?Expires=1785000000 &Signature=Xk9~qL…w_ &Key-Pair-Id=K2JCJMDEHXQW6F Resource The exact URL the policy covers. Scheme, host, path and any query you signed must match byte for byte — a CNAME the distribution does not serve fails as a plain 403. Expires Epoch-UTC seconds. CloudFront rebuilds the canned policy JSON from this number, so the value must be identical to the one you signed. Milliseconds here make the URL immortal. Signature RSA-SHA1 over the policy bytes, base64 with three substitutions: + becomes -, = becomes _, and / becomes ~. Standard base64 verifies locally and 403s at every edge. Key-Pair-Id Public key ID in the trusted key group. Unknown or removed ⇒ 403 with code MissingKey.

The canned policy itself is never transmitted. CloudFront reconstructs it at the edge from Expires and the requested URL, using this exact template with no whitespace at all:

{"Statement":[{"Resource":"<URL>","Condition":{"DateLessThan":{"AWS:EpochTime":<EXPIRES>}}}]}

Your signer must produce that byte sequence character for character. A pretty-printed JSON blob, a trailing newline, or Python’s default ", " separator in json.dumps() all yield a valid RSA signature over the wrong message.

Provision the trust chain

Five AWS resources have to reference each other correctly. Do this once per environment.

#!/usr/bin/env bash
set -euo pipefail

# 1. RSA-2048 in PKCS#8 PEM. CloudFront accepts 1024–2048 bit; 2048 is the only
#    size worth using and is what `create-public-key` expects in SubjectPublicKeyInfo form.
openssl genrsa -out cf-private.pem 2048
openssl rsa -in cf-private.pem -pubout -out cf-public.pem

# 2. Register the PUBLIC key. CallerReference must be unique per call — reusing one
#    returns the existing key instead of creating a new one, which silently breaks rotation.
KEY_ID=$(aws cloudfront create-public-key --public-key-config "{
    \"CallerReference\": \"media-signer-$(date +%s)\",
    \"Name\": \"media-signer-$(date +%Y%m)\",
    \"EncodedKey\": \"$(cat cf-public.pem)\",
    \"Comment\": \"private media signing key\"
  }" --query 'PublicKey.Id' --output text)

# 3. A key group is the unit CloudFront trusts. It holds up to 5 public keys,
#    and that headroom is what makes zero-downtime rotation possible later.
GROUP_ID=$(aws cloudfront create-key-group --key-group-config "{
    \"Name\": \"media-signers\",
    \"Items\": [\"${KEY_ID}\"]
  }" --query 'KeyGroup.Id' --output text)

# 4. Require the key group on the cache behavior that owns /private/*.
#    TrustedKeyGroups is a per-behavior setting: leaving the DEFAULT behavior
#    untrusted keeps posters and CSS public, which is what you want.
#    (Edit the exported config, set TrustedKeyGroups.Enabled=true, Quantity=1,
#     Items=[$GROUP_ID] on the /private/* behavior, then update.)
aws cloudfront get-distribution-config --id "$DIST_ID" > dist.json
ETAG=$(python3 -c 'import json,sys; print(json.load(open("dist.json"))["ETag"])')
# ... apply the edit to dist.json's DistributionConfig ...
aws cloudfront update-distribution --id "$DIST_ID" --if-match "$ETAG" \
  --distribution-config file://dist-config.json

# 5. Lock the origin. Without this the S3 object URL is still reachable and the
#    signature is decoration. OAC signs edge→origin requests with SigV4.
aws s3api put-bucket-policy --bucket media-private --policy "{
  \"Version\": \"2012-10-17\",
  \"Statement\": [{
    \"Effect\": \"Allow\",
    \"Principal\": {\"Service\": \"cloudfront.amazonaws.com\"},
    \"Action\": \"s3:GetObject\",
    \"Resource\": \"arn:aws:s3:::media-private/*\",
    \"Condition\": {\"StringEquals\": {\"AWS:SourceArn\": \"arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}\"}}
  }]
}"

echo "Key-Pair-Id to use in signed URLs: ${KEY_ID}"

Each link in that chain has a distinct failure signature, which is the only thing that makes a 403 diagnosable.

The CloudFront signing trust chain and its failure modes Six linked resources in a serpentine layout: the RSA private key on the signer, the registered CloudFront public key, the key group, the cache behavior with TrustedKeyGroups, the origin access control, and the S3 bucket policy. Under each box a red caption names the failure produced when that link is missing or misconfigured. RSA private key (PEM) signer host only leaked ⇒ anyone mints access CloudFront public key Id K2JCJMDEHXQW6F wrong Id ⇒ 403 MissingKey Key group media-signers, ≤ 5 keys not attached ⇒ 403 everywhere Behavior /private/* TrustedKeyGroups = on wrong pattern ⇒ served unsigned Origin access control SigV4 edge → S3 absent ⇒ origin URL still works S3 bucket policy AWS:SourceArn = this dist public read ⇒ signing is theatre Every link returns the same bare 403 to the browser; only the XML error body and the origin logs tell them apart. Verify the chain in this order — a signer bug and a missing key group are indistinguishable from the client side.

The complete solution: a canned-policy signer

One function, no AWS SDK, no network call. It emits the URL and the wall-clock expiry so your application can decide when to re-issue.

# cf_sign.py — CloudFront canned-policy signed URLs. Python 3.9+, `cryptography` only.
import base64
import time
from urllib.parse import quote, urlencode

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

# CloudFront's base64 alphabet is NOT base64url. Three substitutions, applied to
# both the signature and (for custom policies) the policy blob.
#   '+' -> '-'   '=' -> '_'   '/' -> '~'
_CF_ALPHABET = str.maketrans({"+": "-", "=": "_", "/": "~"})


def _cf_b64(raw: bytes) -> str:
    return base64.b64encode(raw).decode("ascii").translate(_CF_ALPHABET)


def load_private_key(pem_bytes: bytes):
    # Load once at process start, not per request: parsing a PEM costs ~200 µs
    # while the sign itself costs ~1.1 ms, and a gallery signs dozens of URLs.
    return serialization.load_pem_private_key(pem_bytes, password=None)


def sign_url(url: str, key_pair_id: str, private_key, ttl_seconds: int = 900) -> dict:
    """Return a canned-policy signed URL for exactly `url`.

    url           full https URL as the BROWSER will request it, including the
                  distribution's alternate domain name — not the S3 endpoint.
    key_pair_id   the CloudFront public key Id (starts with 'K'), NOT the key group Id.
    ttl_seconds   900 = 15 min. Never go below 60: signer/edge clock skew becomes a
                  meaningful fraction of the lifetime and produces random 403s.
    """
    expires = int(time.time()) + ttl_seconds          # epoch-UTC SECONDS; ms makes the URL immortal

    # The canned policy is reconstructed at the edge from `url` and `expires`.
    # It must be built with NO whitespace and in exactly this key order.
    policy = (
        '{"Statement":[{"Resource":"%s","Condition":'
        '{"DateLessThan":{"AWS:EpochTime":%d}}}]}' % (url, expires)
    ).encode("utf-8")

    # RSA-SHA1 with PKCS#1 v1.5 padding. SHA-1 is not negotiable here: it is what
    # the CloudFront verifier computes, and any other digest fails at the edge.
    signature = private_key.sign(policy, padding.PKCS1v15(), hashes.SHA1())

    query = urlencode(
        {
            "Expires": expires,                       # must equal the value inside the policy
            "Signature": _cf_b64(signature),
            "Key-Pair-Id": key_pair_id,
        },
        quote_via=quote,                              # leaves '-', '_', '~' unescaped — required
        safe="~",                                     # '~' is a valid unreserved char; do not %-encode it
    )

    # A canned policy signs the URL EXACTLY as given. If `url` already has a query
    # string it must be present here too, and nothing may be appended afterwards.
    sep = "&" if "?" in url else "?"
    return {"url": f"{url}{sep}{query}", "expires_at": expires}


if __name__ == "__main__":
    with open("/run/secrets/cf-private.pem", "rb") as fh:   # tmpfs, mode 0400, never in the image
        key = load_private_key(fh.read())

    signed = sign_url(
        "https://media.example.com/private/hero-1080.mp4",
        key_pair_id="K2JCJMDEHXQW6F",
        private_key=key,
        ttl_seconds=3600,           # 1 h: a progressive MP4 keeps issuing Range requests for the whole watch
    )
    print(signed["url"])

Warning: urlencode with the default quote_plus encodes ~ as %7E and spaces as +. CloudFront percent-decodes before verifying, so %7E usually survives — but some intermediary proxies normalise it inconsistently, and the resulting failures are intermittent and host-specific. Pin the encoder as above.

Choosing the TTL

The TTL is not a security dial you can turn arbitrarily tight, because the URL has to outlive the access pattern, not the page view. A <video> element issues Range requests across the entire watch, and each one carries — and re-verifies — the same signature. A signature that expires mid-file turns into a stalled player, not a graceful re-auth.

Required signature lifetime by media access pattern A logarithmic time axis from zero to twenty-four hours. A gallery thumbnail grid keeps requesting for about ten minutes and finishes before the fifteen minute expiry line. A progressive MP4 keeps issuing range requests for roughly two and a half hours and a segmented HLS session for about five hours, both crossing the expiry line and failing mid-playback. How long each pattern keeps requesting the same signed URL 15 min TTL expires Gallery thumbnails safe — 60 AVIF thumbs, one page view, ~10 min Progressive MP4 Range requests for the whole watch, ~2.5 h HLS/DASH session 450 segments + pauses, ~5 h 0 1 min 15 min 1 h 4 h 12 h 24 h time since the URL was issued (log scale) Rule: TTL > longest plausible access window. For segmented playback stop signing URLs and issue signed cookies instead.

Tradeoff: a longer TTL widens the window in which a shared URL still works. Rather than shortening the TTL to compensate, keep the TTL matched to the access pattern and add a second control — a custom policy with an IP condition for one-off downloads, or a shorter-lived signed cookie for a playback session. Both are covered in the parent guide; the point here is that a 60-second URL on a 90-minute film is not “more secure”, it is broken.

Verification

1. Verify the signature locally before blaming the edge

# Rebuild the exact policy bytes your signer produced, then check the signature
# against the PUBLIC key. If this fails, the bug is in your signer, not in AWS.
URL='https://media.example.com/private/hero-1080.mp4'
EXPIRES=1785000000
printf '{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}' \
  "$URL" "$EXPIRES" > /tmp/policy.json

# Reverse CloudFront's alphabet, then base64-decode the signature.
printf '%s' "$SIGNATURE" | tr -- '-_~' '+=/' | base64 -d > /tmp/policy.sig

openssl dgst -sha1 -verify cf-public.pem -signature /tmp/policy.sig /tmp/policy.json
# Verified OK      <- signer is correct; any 403 now comes from the chain or the clock

2. Prove the edge enforces the behavior

# a) Unsigned request must be refused by CloudFront itself.
curl -s -o /dev/null -w '%{http_code}\n' https://media.example.com/private/hero-1080.mp4
# 403
curl -s https://media.example.com/private/hero-1080.mp4 | head -3
# <Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter…

# b) Signed request must succeed AND report a cache status.
curl -sI "$(python3 cf_sign.py)" \
  | grep -iE 'HTTP/|x-cache|age|x-amz-cf-pop|content-length'
# HTTP/2 200
# x-cache: Hit from cloudfront     <- the signature is NOT in the cache key. Correct.
# age: 3184

# c) The public path must stay public — confirm you scoped the behavior, not the distribution.
curl -s -o /dev/null -w '%{http_code}\n' https://media.example.com/public/poster.avif
# 200

Three error bodies cover almost every failure. MissingKey means no Key-Pair-Id reached the edge at all. AccessDenied on a request that did carry all three parameters means the id is not in the trusted key group, or the signature does not match the reconstructed policy. AccessDenied on a request whose signature you have already verified locally means the expiry has passed or the origin refused the edge — check the S3 access log before touching the signer again.

3. Confirm the origin is genuinely closed

# The S3 REST endpoint must reject anonymous reads even though CloudFront can read it.
curl -s -o /dev/null -w '%{http_code}\n' \
  https://media-private.s3.eu-west-1.amazonaws.com/private/hero-1080.mp4
# 403      <- anything else means Block Public Access or the bucket policy is wrong

# And prove the edge itself is reaching the origin with SigV4, not anonymously.
aws s3api get-bucket-policy --bucket media-private --query Policy --output text | python3 -m json.tool

4. Check clock agreement between signer and edge

# CloudFront adjudicates Expires against ITS clock. A signer drifting slow issues
# URLs that die early; drifting fast is harmless for canned policies but fatal for
# custom policies that carry a DateGreaterThan start time.
EDGE=$(curl -sI https://media.example.com/public/poster.avif | awk -F': ' '/^[Dd]ate:/{print $2}' | tr -d '\r')
echo "drift: $(( $(date -u +%s) - $(date -u -d "$EDGE" +%s) )) s"
# drift: 0 s      <- anything past ±5 s should fail your deploy pipeline

Common mistakes

1. Standard base64 instead of CloudFront’s alphabet

This is the single most common cause of “it verifies locally but 403s at the edge”. CloudFront decodes with its own substitution table; feed it +, = or / and the decoded bytes are not your signature.

# WRONG — RFC 4648 base64. Also wrong: base64.urlsafe_b64encode, which maps '/' to '_'.
sig = base64.b64encode(signature).decode()

# CORRECT — three substitutions, in this exact mapping.
sig = base64.b64encode(signature).decode().translate(
    str.maketrans({"+": "-", "=": "_", "/": "~"}))

2. Millisecond epochs in Expires

Date.now() in JavaScript and time.time() * 1000 in Python both give milliseconds. CloudFront reads the value as seconds, so a millisecond timestamp expires roughly 54 000 years from now. The URL works perfectly in testing and never expires in production.

// WRONG — a permanent bearer credential in your CDN logs.
const expires = Date.now() + 900_000;

// CORRECT — seconds.
const expires = Math.floor(Date.now() / 1000) + 900;

3. Signing the origin URL rather than the distribution URL

The canned policy covers the URL byte for byte. Signing https://d111111abcdef8.cloudfront.net/... and serving https://media.example.com/... produces a mismatch, as does signing http:// and requesting https://. So does a distribution whose alternate domain name is not attached to the certificate.

# WRONG — the browser requests the CNAME, the policy names the default domain.
sign_url("https://d111111abcdef8.cloudfront.net/private/hero.mp4", ...)

# CORRECT — sign the host the browser will actually request.
sign_url("https://media.example.com/private/hero.mp4", ...)

4. Appending a cache-buster after signing

A canned policy has no wildcard. Anything appended to the URL after the signature was computed changes the resource being requested and invalidates it — including the ?v= string a framework helpfully adds, and including the ?_= some players attach to defeat caching.

// WRONG — the analytics tag is now part of the requested resource.
const url = signedUrl + '&utm_source=email';

// CORRECT — sign the fully-formed URL, extra parameters included, in one step.
const url = signUrl('https://media.example.com/private/hero.mp4?utm_source=email',);

If you cannot control what gets appended, switch to a custom policy with a wildcard Resource — that is precisely the case it exists for.

5. Trusting the key group on the default behavior only

TrustedKeyGroups is a property of a cache behavior, not of the distribution. A distribution with a default behavior that requires signatures and a more specific /private/* behavior that does not will serve your private media to anyone, because CloudFront matches the most specific path pattern first. Order and specificity both matter; the behavior-matching rules are the same ones described in AWS CloudFront cache behaviors for media, and a signature-driven drop in hit ratio is diagnosed the same way as any other in debugging CloudFront cache misses for images.

Warning: enabling TrustedKeyGroups makes every request under that pattern require a signature, including OPTIONS preflights. If your player fetches segments cross-origin, sign the preflight-triggering request or move the media to a same-origin path — an unsigned preflight returns 403 and the browser reports it as a CORS failure, which sends you debugging the wrong subsystem entirely.