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.
Why CloudFront?
Section titled “Why CloudFront?”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.
Architecture
Section titled “Architecture”┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐│ 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)Request flow
Section titled “Request flow”- Frontend calls
GET /video/:etag/stream-url - Backend validates the ETag, resolves it to the canonical S3 key, derives the filename stem, and checks whether
videos/<stem>/hls/<stem>.m3u8exists in S3- If not: creates a MediaConvert job → returns
{ status: "transcoding" } - If yes: returns
{ status: "ready", url: "https://<domain>/videos/<stem>/hls/<stem>.m3u8" }
- If not: creates a MediaConvert job → returns
- Frontend detects the CloudFront URL and calls
GET /video/:etag/cf-cookies(withwithCredentials: true) - Backend issues three signed cookies covering
https://<domain>/videos/*/hls/*with a configured TTL (default 4 hours) - hls.js loads the master playlist and all subsequent variant manifests and segments — cookies are sent automatically on every request
- CloudFront verifies the cookie signature on each request and fetches objects from S3 via OAC
Key Concepts
Section titled “Key Concepts”ETag vs filename stem
Section titled “ETag vs filename stem”Two distinct identifiers are in play:
| Identifier | Example | Where it comes from |
|---|---|---|
| ETag (32-hex MD5) | 72f7d0a05b8f4fd987bba36b49d9198a | Computed by S3 on upload |
| Filename stem | 8e41c29e8fd8ceee4b32bb21ab537075afdc1e51 | The 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.
Signed cookie scope
Section titled “Signed cookie scope”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.
CloudFront OAC
Section titled “CloudFront OAC”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.
Prerequisites
Section titled “Prerequisites”- 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
bucketbase module) - Terraform ≥ 1.5
opensslinstalled locally
Step 1 — Generate an RSA key pair
Section titled “Step 1 — Generate an RSA key pair”CloudFront uses an RSA-2048 public key to verify signed cookies. Generate the pair locally:
openssl genrsa -out cf_private.pem 2048openssl rsa -in cf_private.pem -pubout -out cf_public.pemKeep cf_private.pem secure — it should only live in secure local handling and in AWS SSM as a SecureString.
Step 2 — Configure tfvars
Section titled “Step 2 — Configure tfvars”Copy iac/root-modules/secure-video-delivery/secret.tfvars to secret.<env>.tfvars (already gitignored):
# secret.dev.tfvarscloudfront_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_pemis used for CloudFront verification.cloudfront_signing_private_key_pemis stored in SSM and used by backend signing.
Fill in the environment-specific values in deploy_<env>.tfvars:
# deploy_dev.tfvarsenvironment = "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 = 14400price_class = "PriceClass_100"Step 3 — Deploy the Terraform module
Section titled “Step 3 — Deploy the Terraform module”cd iac/root-modules/secure-video-delivery
terraform init -backend-config=../../dev.tfbackend
terraform apply \ -var-file=deploy_dev.tfvars \ -var-file=secret.dev.tfvarsNote 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:
aws s3api get-bucket-encryption \ --bucket <course_assets_bucket_name> \ --query "ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.KMSMasterKeyID" \ --output textAdd 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):
aws ssm get-parameter \ --name /mythriq/dev/cloudfront-private-key \ --with-decryption \ --query Parameter.Value \ --output text > cf_private_retrieved.pemFor local runs, set these variables in api/apps/core-api/.env:
CLOUDFRONT_DOMAIN=xxxxxxxxxxxx.cloudfront.netCLOUDFRONT_KEY_PAIR_ID=XXXXXXXXXXXXXXXXXXXXCLOUDFRONT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----CLOUDFRONT_COOKIE_TTL_SECONDS=14400For 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:
awk 'NF {printf "%s\\n", $0}' cf_private.pemWhen deploying through iac/root-modules/kubernetes, these values are injected by IaC from Terraform outputs and SSM (no manual pod env editing).
Step 6 — Verify end-to-end
Section titled “Step 6 — Verify end-to-end”# 1. Upload a test videoaws s3 cp test.mp4 s3://<bucket>/videos/test-video.mp4
# 2. Get the ETagETAG=$(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 cookiescurl -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 cookiescurl -b cf-cookies.txt \ "https://xxxx.cloudfront.net/videos/<stem>/hls/<stem>.m3u8"Local Development (without CloudFront)
Section titled “Local Development (without CloudFront)”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()returnsfalsestream-urlreturns a presigned S3 URL instead of a CloudFront URLVideoStreamService(frontend) does not callcf-cookieshls.jswill 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.
Troubleshooting
Section titled “Troubleshooting”403 on HLS segments
Section titled “403 on HLS segments”- Cookies not set: check that the frontend called
cf-cookieswithwithCredentials: true - Cookie domain mismatch:
sameSite: 'none'+secure: truerequires 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.
stream-url always returns transcoding
Section titled “stream-url always returns transcoding”- Check that MediaConvert completed the job (AWS Console → MediaConvert → Jobs)
- Confirm the HLS manifest path:
videos/<stem>/hls/<stem>.m3u8<stem>= filename of the source file without extension, not the ETag
- If the manifest exists in S3 but the status is still
transcoding, the in-memorytranscodingInProgressset may still be holding the stem — restart the API process
cf-cookies returns 404
Section titled “cf-cookies returns 404”CloudFront is not configured (CLOUDFRONT_DOMAIN env var missing). The endpoint is only active when all three CloudFront env vars are set.
Cookie not attached to hls.js requests
Section titled “Cookie not attached to hls.js requests”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 *).