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.
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 tolearn.example.comand 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.nethostname.
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_IDis 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
Domainon the cookies. - Scope cookies with
Pathto 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
- Using signed cookies with CloudFront: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html
- Setting signed cookies using a custom policy: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-custom-policy.html
- Specifying signers that can create signed URLs and cookies: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html
- MDN, Set-Cookie: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
Add a label
Embed this post
<iframe src="https://www.hibug.me/cloudfront-signed-cookies-403" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.