Skip to content

Video HLS Streaming with CloudFront

This guide explains how video streaming works in Mythriq — why plain S3 presigned URLs don’t work for HLS, and how the CloudFront signed-cookie architecture solves that.


Videos are transcoded to HLS (HTTP Live Streaming) by AWS MediaConvert. An HLS stream is not a single file — it is a master playlist (.m3u8) that references multiple variant playlists, each of which references tens of .ts segment files:

master.m3u8
└─ 720p.m3u8
│ ├─ 720p_00001.ts
│ ├─ 720p_00002.ts
│ └─ ...
└─ 480p.m3u8
├─ 480p_00001.ts
└─ ...

A presigned S3 URL authenticates exactly one object. If you presign the master playlist, hls.js loads it successfully — then immediately fires dozens of unauthenticated requests for variant playlists and segments, all of which return HTTP 403.

CloudFront signed cookies solve this: a single set of three cookies is issued by the backend and scoped to videos/*/hls/*. Every subsequent request hls.js makes for any file under that path carries those cookies automatically.


┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ Squidex CMS │─────▶│ AI Service │─────▶│ Angular Frontend │
│ (content) │ tag │ (streaming) │ SSE │ video-player │
└──────────────┘ └──────────────┘ └───────────┬──────────┘
1. GET /video/:etag/stream-url
2. GET /video/:etag/cf-cookies ← sets 3 cookies
┌─────────────────────┐
│ Core API │
│ VideoController │
│ CloudFrontService │
└──────────┬──────────┘
┌────────────┴────────────┐
▼ ▼
┌────────────┐ ┌──────────────────┐
│ S3 Bucket │◀───────│ MediaConvert │
│ (private) │ HLS │ (on-demand │
└─────┬──────┘ out │ transcoding) │
│ └──────────────────┘
│ S3 origin (OAC — no public access)
┌────────────┐
│ CloudFront │ ← caches manifests + segments
│ distribution│ verifies signed cookies
└─────┬──────┘
│ HTTPS + cookies
hls.js / Safari
(withCredentials)
  1. Frontend calls GET /video/:etag/stream-url
  2. Backend validates the ETag, resolves it to the canonical S3 key, derives the filename stem, and checks whether videos/<stem>/hls/<stem>.m3u8 exists in S3
    • If not: creates a MediaConvert job → returns { status: "transcoding" }
    • If yes: returns { status: "ready", url: "https://<domain>/videos/<stem>/hls/<stem>.m3u8" }
  3. Frontend detects the CloudFront URL and calls GET /video/:etag/cf-cookies (with withCredentials: true)
  4. Backend issues three signed cookies covering https://<domain>/videos/*/hls/* with a configured TTL (default 4 hours)
  5. hls.js loads the master playlist and all subsequent variant manifests and segments — cookies are sent automatically on every request
  6. CloudFront verifies the cookie signature on each request and fetches objects from S3 via OAC

Two distinct identifiers are in play:

IdentifierExampleWhere it comes from
ETag (32-hex MD5)72f7d0a05b8f4fd987bba36b49d9198aComputed by S3 on upload
Filename stem8e41c29e8fd8ceee4b32bb21ab537075afdc1e51The source filename without extension

The ETag is the external video identifier — it’s what gets stored in Squidex content and passed to the API. The filename stem is what MediaConvert uses for all output filenames. These are completely different strings.

The backend flow: ETag → ListObjectsV2 scan → canonical key → strip extension → stem → S3 paths.

Cookies are scoped to https://<domain>/videos/*/hls/* (a CloudFront custom policy). They authorise access to all HLS files for any video in the same bucket — not just one video. This is intentional: a user watching one video in a learning unit shouldn’t need a new cookie for the next video in the same session.

The S3 bucket remains fully private. CloudFront accesses it via Origin Access Control (OAC), which uses short-lived SigV4 credentials managed by AWS. Only requests arriving through the CloudFront distribution can reach the objects.


  • An AWS account with permissions to create CloudFront distributions, S3 bucket policies, SSM parameters, and KMS key policies
  • The course-assets S3 bucket already provisioned (created by the bucket base module)
  • Terraform ≥ 1.5
  • openssl installed locally

CloudFront uses an RSA-2048 public key to verify signed cookies. Generate the pair locally:

Terminal window
openssl genrsa -out cf_private.pem 2048
openssl rsa -in cf_private.pem -pubout -out cf_public.pem

Keep cf_private.pem secure — it should only live in secure local handling and in AWS SSM as a SecureString.


Copy iac/root-modules/secure-video-delivery/secret.tfvars to secret.<env>.tfvars (already gitignored):

# secret.dev.tfvars
cloudfront_public_key_pem = "<contents of cf_public.pem>"
cloudfront_signing_private_key_pem = "<contents of cf_private.pem with \\n escapes>"

Note:

  • cloudfront_public_key_pem is used for CloudFront verification.
  • cloudfront_signing_private_key_pem is stored in SSM and used by backend signing.

Fill in the environment-specific values in deploy_<env>.tfvars:

# deploy_dev.tfvars
environment = "dev"
course_assets_bucket_name = "mythriq-course-assets-bucket-123456789012"
course_assets_bucket_arn = "arn:aws:s3:::mythriq-course-assets-bucket-123456789012"
hls_prefix = "videos/"
cookie_ttl_seconds = 14400
price_class = "PriceClass_100"

Terminal window
cd iac/root-modules/secure-video-delivery
terraform init -backend-config=../../dev.tfbackend
terraform apply \
-var-file=deploy_dev.tfvars \
-var-file=secret.dev.tfvars

Note the outputs:

cloudfront_domain = "xxxxxxxxxxxx.cloudfront.net"
cloudfront_key_pair_id = "XXXXXXXXXXXXXXXXXXXX"
cloudfront_private_key_ssm_param = "/mythriq/dev/cloudfront-private-key"

Step 4 — Grant CloudFront access to the KMS key

Section titled “Step 4 — Grant CloudFront access to the KMS key”

If the course-assets bucket uses SSE-KMS encryption (the default in this project), CloudFront’s OAC service principal needs kms:Decrypt permission on the KMS key. Without it, every object fetch returns a 403, even though the S3 bucket policy is correct.

Retrieve the key ARN:

Terminal window
aws s3api get-bucket-encryption \
--bucket <course_assets_bucket_name> \
--query "ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.KMSMasterKeyID" \
--output text

Add a statement to the KMS key policy:

{
"Sid": "AllowCloudFrontOAC",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "kms:Decrypt",
"Resource": "*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DISTRIBUTION_ID>"
}
}
}

Replace <ACCOUNT_ID> and <DISTRIBUTION_ID> with the values from your Terraform outputs or the CloudFront console.


Step 5 — Configure backend environment variables

Section titled “Step 5 — Configure backend environment variables”

Retrieve the private key from SSM (or use the PEM file directly):

Terminal window
aws ssm get-parameter \
--name /mythriq/dev/cloudfront-private-key \
--with-decryption \
--query Parameter.Value \
--output text > cf_private_retrieved.pem

For local runs, set these variables in api/apps/core-api/.env:

CLOUDFRONT_DOMAIN=xxxxxxxxxxxx.cloudfront.net
CLOUDFRONT_KEY_PAIR_ID=XXXXXXXXXXXXXXXXXXXX
CLOUDFRONT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----
CLOUDFRONT_COOKIE_TTL_SECONDS=14400

For CLOUDFRONT_PRIVATE_KEY, the value must be the PEM string with literal \n sequences (not actual newlines) so it survives .env parsing. You can convert it:

Terminal window
awk 'NF {printf "%s\\n", $0}' cf_private.pem

When deploying through iac/root-modules/kubernetes, these values are injected by IaC from Terraform outputs and SSM (no manual pod env editing).


Terminal window
# 1. Upload a test video
aws s3 cp test.mp4 s3://<bucket>/videos/test-video.mp4
# 2. Get the ETag
ETAG=$(aws s3api head-object \
--bucket <bucket> \
--key videos/test-video.mp4 \
--query ETag --output text | tr -d '"')
echo $ETAG
# 3. Trigger transcoding / check status (requires JWT)
curl -H "Authorization: Bearer <token>" \
http://localhost:8080/api/v1/core/video/$ETAG/stream-url
# → { "status": "transcoding" }
# After 1–3 min:
# → { "status": "ready", "url": "https://xxxx.cloudfront.net/videos/.../hls/....m3u8" }
# 4. Issue CloudFront cookies
curl -H "Authorization: Bearer <token>" \
-c cf-cookies.txt \
http://localhost:8080/api/v1/core/video/$ETAG/cf-cookies
# 5. Fetch the master playlist using the cookies
curl -b cf-cookies.txt \
"https://xxxx.cloudfront.net/videos/<stem>/hls/<stem>.m3u8"

When CLOUDFRONT_DOMAIN, CLOUDFRONT_KEY_PAIR_ID, and CLOUDFRONT_PRIVATE_KEY are not set in the local .env, the backend falls back gracefully:

  • CloudFrontService.isConfigured() returns false
  • stream-url returns a presigned S3 URL instead of a CloudFront URL
  • VideoStreamService (frontend) does not call cf-cookies
  • hls.js will load the master manifest from the presigned URL but the segment requests will fail (403) — this is expected in local dev

To fully test HLS playback locally you need either:

  • Full CloudFront deployed (not typical for local dev), or
  • A tunnel to a deployed environment

For unit testing the transcoding logic and status polling, presigned-URL fallback is sufficient.


  • Cookies not set: check that the frontend called cf-cookies with withCredentials: true
  • Cookie domain mismatch: sameSite: 'none' + secure: true requires HTTPS; local http will not send cookies
  • Cookies expired: default TTL is 4 hours; re-trigger by refreshing the page

403 from CloudFront with KMS.DisabledException or AccessDenied

Section titled “403 from CloudFront with KMS.DisabledException or AccessDenied”

The CloudFront OAC does not have kms:Decrypt on the bucket’s KMS key — see Step 4.

  1. Check that MediaConvert completed the job (AWS Console → MediaConvert → Jobs)
  2. Confirm the HLS manifest path: videos/<stem>/hls/<stem>.m3u8
    • <stem> = filename of the source file without extension, not the ETag
  3. If the manifest exists in S3 but the status is still transcoding, the in-memory transcodingInProgress set may still be holding the stem — restart the API process

CloudFront is not configured (CLOUDFRONT_DOMAIN env var missing). The endpoint is only active when all three CloudFront env vars are set.

Confirm xhrSetup: xhr => { xhr.withCredentials = true; } is present in the hls.js config in video-player.component.ts. Also verify that the API server sets Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin (not *).