Web Development Bug Report

CloudFront signed cookies set, every request still 403: cookie domain, path and key groups

We moved private course videos behind CloudFront signed cookies. The app set the three cookies, yet the browser got 403 Missing Key-Pair-Id for every segment: cookies were scoped to the app's host, not the CDN's, and the behaviour trusted a different key group. How we debugged it with curl.

Tomás Herrera Tomás Herrera @tomasherrera · October 10, 2026 · 8 min read · 114 Views
Banner: CloudFront signed cookies set, every request still 403, cookie domain, path and key groups

Our learning platform serves course videos as HLS streams: a playlist file and hundreds of small segments per video. Signed URLs for each segment were impractical, so we chose CloudFront signed cookies: after checking that a user has access to a course, the app sets three cookies, and CloudFront lets the browser fetch any file under that course's path.

The app is at app.learn.example.com, videos at media.learn.example.com (a CloudFront distribution with an S3 origin). After deployment, the player showed an error, and every request from the browser to the media domain returned:

HTTP/2 403
content-type: text/xml
x-cache: Error from cloudfront

<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code>
<Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

Step 1: were the cookies sent?

In the browser's developer tools, the request to media.learn.example.com had no Cookie header at all. The response that set them, from the app's login callback:

set-cookie: CloudFront-Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoi...; Path=/; Secure; HttpOnly; SameSite=Lax
set-cookie: CloudFront-Signature=Wm9k...; Path=/; Secure; HttpOnly; SameSite=Lax
set-cookie: CloudFront-Key-Pair-Id=K2JCJMDEHXQW5F; Path=/; Secure; HttpOnly; SameSite=Lax

No Domain attribute. Without it, a cookie is host-only: the browser stores it for app.learn.example.com and sends it only there. The media domain never saw them.

Step 2: with the right domain, still 403

We added Domain=learn.example.com, which makes the cookies valid for the parent domain and all its subdomains. The browser now sent them to media.learn.example.com. The error changed:

<Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

Progress, and a different problem. To separate browser behaviour from CloudFront behaviour, we reproduced the request with curl, pasting the cookie values:

curl -sI "https://media.learn.example.com/courses/c-1042/hls/index.m3u8" \
  -H "Cookie: CloudFront-Policy=eyJ...; CloudFront-Signature=Wm9k...; CloudFront-Key-Pair-Id=K2JCJMDEHXQW5F"

Still 403, so the cookies themselves were rejected.

Step 3: decode the policy

The CloudFront-Policy cookie is a CloudFront-flavoured Base64 encoding of the JSON policy (with -, _ and ~ replacing +, = and /). Decoding it:

echo 'eyJ...' | tr -- '-_~' '+=/' | base64 -d
{"Statement":[{"Resource":"https://media.learn.example.com/courses/c-1042/*",
  "Condition":{"DateLessThan":{"AWS:EpochTime":1760180400}}}]}

The resource and expiry looked right. That left the signature and key.

Step 4: which key does the behaviour trust?

aws cloudfront get-distribution-config --id E1A2B3C4D5E6F7 \
  --query 'DistributionConfig.CacheBehaviors.Items[?PathPattern==`courses/*`].TrustedKeyGroups'
[{ "Enabled": true, "Quantity": 1, "Items": ["kg-0a1b2c3d4e5f"] }]
aws cloudfront get-key-group --id kg-0a1b2c3d4e5f --query 'KeyGroup.KeyGroupConfig.Items'
# ["K1R9ZQ4VXW7T2M"]

The behaviour trusted key group kg-0a1b2c3d4e5f, which contained public key K1R9ZQ4VXW7T2M. The app signed with the private key matching K2JCJMDEHXQW5F, a key created in staging. The production configuration had the wrong key id and private key.

What we ruled out

  • Clock skew. The expiry was hours in the future; the server clocks were NTP-synced.
  • Origin access. Requests with a valid signed URL generated by an admin script for the right key succeeded, so the S3 origin and origin access control were fine.
  • The cache serving old 403s. CloudFront does not cache 403 responses from signature checks; they are generated at the edge before the cache.

Root cause

Two problems stacked. The cookies were set without a Domain attribute, so the browser never sent them to the CDN hostname. Once that was fixed, the cookies were signed with a key that the distribution's cache behaviour did not trust, because the production deployment used staging's key pair id and private key.

How CloudFront validates signed cookies

For a behaviour that restricts viewer access, CloudFront requires three cookies on the request:

Cookie Content
CloudFront-Policy (custom policy) or CloudFront-Expires (canned policy) What is allowed and until when
CloudFront-Signature RSA signature of the policy with your private key
CloudFront-Key-Pair-Id Which public key in a trusted key group verifies the signature

CloudFront looks up the key id in the behaviour's trusted key groups, verifies the signature, then checks the policy against the request URL, time and optionally IP address. A custom policy with a wildcard resource, as above, covers every file under a path, which is what makes cookies practical for HLS.

Cookie scoping rules

Cookies are sent based on Domain, Path, Secure and SameSite:

  • No Domain: sent only to the exact host that set them.
  • Domain=learn.example.com: sent to learn.example.com and every subdomain.
  • The app can only set cookies for its own domain or a parent domain; it cannot set cookies for an unrelated domain. That is why the CDN must be on a subdomain of a domain the app controls, not on the default d111111abcdef8.cloudfront.net hostname.

Environment

Component Value
App app.learn.example.com, Node.js backend
CDN CloudFront distribution, alternate domain media.learn.example.com
Origin S3 bucket with origin access control
Access control Trusted key group on behaviour courses/*
Content HLS playlists and segments

Correct cookie attributes

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

const policy = JSON.stringify({
  Statement: [{
    Resource: `https://media.learn.example.com/courses/${courseId}/*`,
    Condition: { DateLessThan: { "AWS:EpochTime": Math.floor(Date.now() / 1000) + 4 * 3600 } },
  }],
});

const cookies = getSignedCookies({
  policy,
  keyPairId: process.env.CF_KEY_PAIR_ID,
  privateKey: await getPrivateKeyFromSecretsManager(),
});

const opts = { domain: "learn.example.com", path: `/courses/${courseId}/`, secure: true,
               httpOnly: true, sameSite: "lax", maxAge: 4 * 3600 * 1000 };
res.cookie("CloudFront-Policy", cookies["CloudFront-Policy"], opts);
res.cookie("CloudFront-Signature", cookies["CloudFront-Signature"], opts);
res.cookie("CloudFront-Key-Pair-Id", cookies["CloudFront-Key-Pair-Id"], opts);

Setting Path to the course prefix means a user enrolled in several courses gets one cookie set per course, each sent only for its own path, instead of one set overwriting another.

Correct keys per environment

The key pair id and private key are now provisioned together by Terraform per environment, with the private key stored in Secrets Manager and the id passed as configuration from the same module:

resource "aws_cloudfront_public_key" "media" {
  name        = "media-${var.environment}"
  encoded_key = file("${path.module}/keys/media-${var.environment}.pub.pem")
}

resource "aws_cloudfront_key_group" "media" {
  name  = "media-${var.environment}"
  items = [aws_cloudfront_public_key.media.id]
}

output "cf_key_pair_id" {
  value = aws_cloudfront_public_key.media.id
}

Alternatives and trade-offs

  • Signed URLs for single files such as downloads; simpler, no cookie scoping issues.
  • Lambda@Edge or CloudFront Functions validating your own session token. More flexible, but you maintain the auth logic at the edge.

How to test the fix

# after logging in as a test user, extract the cookie jar from the app response
curl -s -c jar.txt -b jar.txt -o /dev/null "https://app.learn.example.com/api/courses/c-1042/access"
curl -s -b jar.txt -o /dev/null -w '%{http_code}\n' "https://media.learn.example.com/courses/c-1042/hls/index.m3u8"
# 200
curl -s -b jar.txt -o /dev/null -w '%{http_code}\n' "https://media.learn.example.com/courses/c-9999/hls/index.m3u8"
# 403 (not enrolled)

Confirming in production

Video playback worked for enrolled users, and requests for other courses' paths returned 403 as intended. CloudFront standard logs showed 403s on courses/* dropping to a small background of expired cookies.

Regression guard

  • An end-to-end test in the deploy pipeline performs the curl sequence above against each environment.
  • A startup check in the app verifies that CF_KEY_PAIR_ID is a member of the expected key group via the CloudFront API, failing fast on mismatched configuration.

Security implications

Domain=learn.example.com sends the cookies to every subdomain, including any marketing or support tools hosted under it. They are scoped by Path and expire in four hours, and they only grant read access to media for one course, so the exposure is limited. Never use a canned policy that covers all paths, and keep the private key in a secrets manager, rotating it with a second key in the key group during the transition.

Prevention checklist

  • Serve signed content from a subdomain of the app's domain and set Domain on the cookies.
  • Scope cookies with Path to the resource prefix they grant.
  • Provision key pair id and private key together per environment.
  • Debug with curl to separate browser cookie behaviour from CloudFront validation.
  • Decode the policy cookie to check resource and expiry.

References

Add a label

#cookies #s3 #cdn #aws-cloudfront #signed-cookies
Written by
Tomás Herrera @tomasherrera

I build and operate event-driven systems on AWS: Lambda, Step Functions, SQS, SNS, EventBridge and ECS on Fargate, mostly for e-commerce and logistics workloads where a stuck message means a stuck order. Serverless removes a lot of work, but it also hides limits until the day you hit them: payload sizes, trigger timeouts, scale-in behaviour, resource policies that silently drop events. I write down each one we hit, with the exact error, the configuration that caused it and the change that fixed it, so you can find the limit in a blog post instead of an incident channel.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.