Fix CloudFront Signed URL/Cookie 403 Access Denied: Key Group Setup, Key-Pair-ID Mismatch & Invalid Signature Causes
A CloudFront signed URL or signed cookie almost never gets rejected because the signature itself is "wrong." In the huge majority of real cases, the request is being checked against the wrong key group entirely — either the Key-Pair-Id doesn't match any public key CloudFront can see for that specific cache behavior, or the policy statement inside the URL has a base URL, date, or IP condition that no longer lines up with the request. Fix the key group setup and the cache behavior it's attached to first, and the "signature invalid" panic usually disappears on its own.
Jake runs a small phone repair and resale shop, and he emails customers a PDF diagnostic report and an itemized repair quote through a link his point-of-sale software generates. The link is a CloudFront signed URL pointing at a private S3 bucket — nothing fancy, just a report a customer shouldn't be able to guess the URL of. Last Tuesday, every single one of those links started coming back "Access Denied." Nothing in his code had changed.
"I didn't touch it," he told his mentor Ethan on the phone, sounding like every developer who has ever opened a CloudFront 403 page. "I didn't touch anything."
"Nobody ever touches anything," Ethan said. "Send me the raw error text CloudFront gave you, not just '403.' The 403 is the outer wrapper. The actual reason is a sentence buried in the response body, and that sentence is the whole troubleshooting process."
π‘ MUST READ FIRST
Signed URL errors make zero sense until you understand CloudFront's private content model — start here:
- π What is CloudFront? The CDN model that explains why private content needs signatures at all
- π AWS presigned URLs explained — the S3 cousin that's NOT the same as CloudFront signed URLs (critical difference)
- π CloudFront + custom domain + HTTPS — the domain scope that signed cookies must match exactly
That's the mindset this post is built around. CloudFront's signed URL and signed cookie system runs on exactly three moving parts — a public/private key pair, a key group that holds the public key, and a cache behavior that trusts that key group — and almost every rejection traces back to one of those three being slightly out of alignment with the other two. We'll go symptom by symptom, using the exact error text CloudFront returns, because that text tells you which of the three parts is broken.
How signed URLs, signed cookies, and key groups actually fit together
Before touching the error messages, it helps to have the mental model straight, because most "signature rejected" tickets are actually a mismatch in this chain, not a broken cryptographic signature.
A key pair is an SSH-2 RSA 2048 or ECDSA 256 public/private key pair — two names for the math CloudFront uses to lock and unlock a signature, the same way a physical lock and its matching key are two halves of one system, except here the "key" is a very long number instead of a piece of metal. It's stored in base64-encoded PEM format, which just means a block of plain, keyboard-typeable text that represents that long number — the same kind of block you'd get out of an OpenSSL command (OpenSSL being the free tool almost everyone uses to generate these). You keep the private key secret on your server; it's what signs the URL or cookie. You upload the public key to CloudFront, where it does nothing on its own until it's placed inside a key group. A key group is just a named container that can hold up to five public keys. The key group, in turn, is attached to a specific cache behavior inside your distribution — the rule that says "requests matching this path pattern go here." That's the step people skip: attaching the key group to the behavior, and specifically, attaching it to the correct behavior.
When a cache behavior has a key group attached (CloudFront calls this "Restrict Viewer Access"), every request matching that behavior's path pattern must arrive with a signed URL or signed cookie. CloudFront reads the Key-Pair-Id out of the request, looks it up against every public key in every key group attached to that specific behavior, and if it finds a match, verifies the signature using that public key. If it doesn't find a match — wrong key group, key group on a different behavior, or no key group at all — the request is rejected before CloudFront even bothers to check whether the signature itself is mathematically valid.
♂️ Jake's Reality Check
"So if I attach my key group to the wrong path pattern, CloudFront doesn't even look at whether my code signed the URL correctly? It just... gives up first?"
Yes. The key-group lookup happens before signature verification. A perfectly signed URL against the wrong cache behavior fails exactly the same way as an unsigned request — there's no partial credit.
Diagnose the exact error before you change anything
Open the response body of the failing request — not just the browser's network tab summary, the actual XML CloudFront sends back. Every symptom below maps to a different repair, so don't skip this step even if you're sure you already know the cause.
| What the error body says | What it means | Jump to |
|---|---|---|
| "Missing Key-Pair-Id query parameter or cookie value" | CloudFront never received a Key-Pair-Id at all — the signing step didn't run, or the parameter got stripped. | Section below |
"Unknown Key" / InvalidKey |
A Key-Pair-Id arrived, but it isn't in any key group attached to the matching cache behavior. | Section below |
| Just "Access Denied," no extra detail, and the URL used to work | Almost always the policy statement's expiry has passed, or the resource URL in the policy no longer matches the request. | Section below |
| Works from your test script, fails from the real app / browser | Usually a base URL, protocol, or domain mismatch between the policy and the real request. | Section below |
| Signed cookie worked once, then stopped on later page loads | Missing or mismatched Domain/Path attributes on the Set-Cookie response headers. | Section below |
"Missing Key-Pair-Id query parameter or cookie value"
This message means exactly what it says: CloudFront looked for a Key-Pair-Id and found nothing usable. For a signed URL, that's an empty or absent Key-Pair-Id query string parameter. For a signed cookie, it's a missing CloudFront-Key-Pair-ID cookie value. This is not a signature problem yet — CloudFront hasn't gotten far enough to check a signature.
Common causes for signed URLs
- Your signing code ran and returned a value, but something downstream — a reverse proxy, a URL-shortening step, an email client that "cleans" links — stripped the query string before the request reached CloudFront.
- The signing library errored silently and returned the original, unsigned URL instead of throwing.
- The application is generating the link for a cache behavior that doesn't actually require signing, so no Key-Pair-Id was ever appended, and then the request accidentally hits a different, restricted behavior.
Common causes for signed cookies
Signed cookies require three separate Set-Cookie headers — each header can only carry one name-value pair, and CloudFront needs all three: the policy (or expiry, for a canned policy), the signature, and the Key-Pair-Id. If your server sends only one combined header, or a browser extension or ad blocker strips cookies from a cross-site response, the Key-Pair-Id cookie never arrives, and this is the exact message you'll get.
⚠️ What this actually breaks
If you're testing signed cookies with a request tool that doesn't automatically persist Set-Cookie headers across requests (some HTTP client GUIs need cookie jars enabled explicitly), you'll see this error on the second request even though the first one succeeded and set the cookies correctly. Check your tool's cookie-jar setting before assuming CloudFront is at fault.
"Unknown Key" — the signature CloudFront can't place
This is the error that convinces people their signature generation code is broken, and it's almost never that. "Unknown Key" means CloudFront received a Key-Pair-Id but couldn't find a public key matching it among the trusted signers on the matching cache behavior. Walk through it in order:
- Open the CloudFront console, select your distribution, and go to the Behaviors tab.
- Select the behavior whose path pattern matches the file you're requesting, and check Restrict Viewer Access. If it's not set to Yes, this behavior isn't even enforcing signing — your request is hitting a different behavior than you think.
- Check the Trusted authorization type field. If it says Trusted key groups, note the key group name(s) listed. If it says Trusted Signer, this distribution is using the older AWS-account signer method, not a key group.
- Go to Key groups in the left navigation, open the key group named in step 3, and look at the public key IDs it contains.
- Compare that list against the Key-Pair-Id value your failing request actually sent. If it isn't in the list, that's the entire bug — either you're signing with the wrong private key, or the right public key was never added to this key group.
The cache-behavior trap: a correct key can still be "unknown"
Here's the counterintuitive part. Signers are scoped to the cache behavior they're attached to, not to the distribution as a whole. If you have two cache behaviors — say, one for /reports/* and one for /invoices/* — and Key Group A is attached only to /reports/*, then a URL signed with Key Group A's private key will fail with "Unknown Key" against anything matching /invoices/*, even though the signature itself is completely valid. The private key you used is real, the signature math is correct, and CloudFront still rejects it, because it's checking a different behavior's trusted signer list.
This matters even more when path patterns overlap. If a request matches the path pattern of two cache behaviors, CloudFront uses whichever behavior comes first in the ordered list — not the "more specific" one and not the one you meant. A broad behavior with no restriction, placed ahead of a narrower restricted one, will happily serve the file unsigned; a restricted one placed ahead of the one you intended to use will reject a URL signed for the other behavior's key group. Review the order of your cache behaviors, not just their existence.
The InvalidKey pattern support engineers see repeatedly
- Create a CloudFront key pair, upload the public key, and add it to a key group — each step succeeds individually.
- Attach the key group to a behavior, generate a signed URL with the AWS CLI's
cloudfront signcommand (the CLI, or Command Line Interface, is AWS's tool for typing commands in a terminal window instead of clicking through the console) or an SDK (a ready-made code library AWS publishes for languages like Python, Java, and Node.js, so you don't have to write the signing logic yourself from scratch), and still get<Error><Code>InvalidKey</Code><Message>Unknown Key</Message></Error>. - The cause is nearly always one of: the key group was attached to a different distribution or behavior than the one being requested, the public key ID used to sign doesn't match the one actually uploaded (a stale ID copied from an earlier attempt), or the distribution hasn't finished deploying the new behavior configuration yet.
Building the key pair and key group correctly, from zero
If your setup has drifted and you're not sure where, it's often faster to rebuild the key pair and key group cleanly than to keep chasing a stale ID. AWS recommends trusted key groups over the older AWS-account key pair method, and one practical reason is right there in the console: creating a CloudFront key pair under the AWS account root user requires signing in as the root user specifically — IAM users can't do it. (IAM stands for Identity and Access Management — it's the AWS system for creating individual staff logins instead of everyone sharing the one master account password, the way a shop might give each employee their own key to the register instead of handing out the owner's master key.) You're also capped at two active key pairs per account under the old method. Key groups have none of that restriction; any IAM user with the right permissions can manage them through the console or the API, and you can attach up to four key groups to a single distribution, each holding up to five public keys.
- Generate the key pair. Using OpenSSL, an RSA 2048 pair is generated with
openssl genrsa -out private_key.pem 2048, or an ECDSA prime256v1 pair withopenssl ecparam -name prime256v1 -genkey -noout -out privatekey.pem. Extract the public half withopenssl rsa -pubout -in private_key.pem -out public_key.pem. The key must end up base64-encoded PEM — the plain text block starting with-----BEGIN PUBLIC KEY-----. - Upload the public key to CloudFront. In the console, go to Public keys → Create public key, name it something you'll recognize later, and paste the contents of
public_key.pemexactly as-is — including the BEGIN/END lines. Record the key ID CloudFront assigns; you'll need it as the Key-Pair-Id value when you sign URLs. - Create the key group. Go to Key groups → Add key group, name it, and select the public key from step 2. Record the key group's ID.
- Attach it to the correct cache behavior. On the distribution's Behaviors tab, edit the behavior whose path pattern matches the content you're protecting. Set Restrict Viewer Access to Yes, choose Trusted Key Groups, and select the key group from step 3.
- Wait for the deployment to finish before testing. Behavior changes take effect once the distribution status returns to Deployed, not the instant you save.
- Sign with the matching private key and key ID. The private key never leaves your server; only its public half was ever uploaded. Double-check that the Key-Pair-Id in your signing code is the ID CloudFront assigned in step 2, not a name you made up or an ID copied from a different key.
✅ Why this is the one to use
Trusted key groups are the recommended signer for new setups. They let you manage keys through the CloudFront API instead of the console-only root-user workflow, they support IAM permission policies that restrict who can add or remove keys, and they let you hold more active keys than the account-level key pair method ever allowed. If you're setting this up fresh, don't reach for a trusted signer AWS account — go straight to a key group.
A detail that trips people up on step 4: the "Add" button in the key group selector on the Edit Behavior page doesn't save anything on its own. You still have to click Yes, Edit (or Save, depending on which console layout you're on) to actually commit the change to the distribution. It's easy to walk away thinking the key group is attached because the console showed it in the list, when really the edit was never confirmed and the distribution is still running its previous configuration. If a key group looks correctly selected in the UI but "Unknown Key" persists, reopen the behavior and check whether the change actually saved.
If you're generating signatures in .NET or Java
The default PEM-formatted private key won't work as-is with .NET or Java signing libraries. For .NET, convert the private key to the XML format the framework expects. For Java, convert it to DER format first — DER is just a different, more compact way of packaging the same key, the binary cousin of the plain-text PEM block — with openssl pkcs8 -topk8 -nocrypt -in private_key.pem -inform PEM -out private_key.der -outform DER, and add the Bouncy Castle cryptography library (a widely used, free toolkit that gives Java programs the encryption math it doesn't include out of the box) to your project so the encoder can read it correctly. Skipping this conversion is a frequent, silent cause of signatures that look fine to your code but never verify on CloudFront's end.
Reading your own policy statement before you assume it's broken
A canned policy signed URL carries an Expires query parameter directly, in Unix epoch time — a running count of seconds since January 1, 1970, which is just computing's shared starting point for measuring time, the way a stopwatch counts up from zero instead of showing a clock face. A custom policy instead carries a base64-encoded Policy parameter — a full JSON statement (JSON is a plain-text way of writing structured information as labeled values inside curly braces, similar to a short, nested list) you can decode and read yourself. Signed cookies work the same way, using CloudFront-Expires or CloudFront-Policy.
To read a custom policy, take the value of the Policy (or CloudFront-Policy) parameter and run it through a standard decode, swapping CloudFront's URL-safe replacement characters back first:
echo "<your-policy-value>" | tr -- '-_~' '+=/' | base64 -d
That gives you a plain JSON object with a Statement array. Check three things in order:
- Exactly one statement. If the array has more than one entry, CloudFront rejects the request outright — canned and custom policies alike are only valid with a single statement.
- The date conditions.
DateLessThan(and, if present,DateGreaterThan) are epoch seconds. A signed URL sent afterDateLessThan, or beforeDateGreaterThan, is rejected as expired — even by a few seconds. If you're using epoch time on a 32-bit integer, the practical ceiling is 2147483647, which corresponds to January 19, 2038; dates past that need a 64-bit epoch value or a different approach. - The Resource value — covered next, because it has its own set of ways to go wrong.
Base URL mismatches: the Resource key has to match the real request exactly
CloudFront compares the Resource (or the canned-policy base URL) against the actual incoming request, and it is not forgiving about near-matches. Each of the following causes a rejection on its own:
- An abbreviated host in the Resource key, like
www.example.com, instead of a complete URL with scheme —http://www.example.comorhttps://www.example.com. - Missing UTF-8 character encoding on the URL.
- A Resource value that leaves out punctuation or query string parameters that the real request includes.
- A protocol mismatch — the policy says
http://but the real request arrives over HTTPS, or the reverse. - A domain mismatch — the policy's host doesn't match the Host header the viewer actually sent, which happens often when a link is generated for the CloudFront default domain (
d111111abcdef8.cloudfront.net) but served through an alternate domain name, or vice versa. - Invalid characters left un-encoded in the query string.
♂️ Jake's Reality Check
"My test script signs a URL and it works. My actual point-of-sale app generates a link that looks identical and it fails. How is that possible?"
It's rarely identical. Compare the two URLs character by character, especially the scheme, the host, and anything after the ?. A trailing slash, an environment variable pointing at the wrong domain, or a proxy that rewrites https to http before your app signs the request are all common, and every one of them breaks the Resource match.
Signature errors: what breaks when you sign without an SDK
If your policy decodes cleanly, the dates are valid, and the Resource matches exactly, but you still get rejected, look at how the signature itself was produced. CloudFront returns an Access Denied error in each of these cases:
- The policy statement contains whitespace — including tabs and newline characters — before it's hashed. A pretty-printed JSON policy with indentation will fail; it needs to be compact, single-line JSON.
- The policy wasn't converted to a string in the exact format CloudFront expects before hashing. This is a common bug when signing is implemented by hand instead of through an AWS SDK.
- The policy was signed without being hashed first, or hashed with the wrong algorithm.
Ethan's opinion on this one is blunt: "The moment you're hand-rolling policy-statement signing instead of using an SDK's built-in signer, you've taken on a maintenance job nobody asked you to do. I've watched teams spend a week debugging a signature mismatch that an SDK call would have handled in one line." Reach for the AWS SDK's signed URL or signed cookie helper for your language before writing custom signing code from scratch — it handles the exact string formatting and hashing order CloudFront expects.
Signed cookies specifically: Domain and Path attributes
Signed cookies have a failure mode signed URLs don't: the cookies come back from CloudFront correctly on the first request but simply aren't included in later requests to the same domain. When that happens, check the Domain and Path attributes on the Set-Cookie response headers your application sends.
If you don't specify a Domain attribute, it defaults to the exact domain name in the URL, and applies only to that domain — not subdomains. If you do specify one, it applies to subdomains too, but it must match the domain name in the URL. You can use the CloudFront-assigned domain (d111111abcdef8.cloudfront.net), but you cannot use a wildcard like *.cloudfront.net; to serve from a custom domain such as files.example.com, that domain needs to be added as an alternate domain name on the distribution first, and your signed cookie code needs to target it directly.
The Path attribute works the same way — if you leave it unset, it defaults to the path in the URL that set the cookie, and requests for files outside that path won't carry the cookie along. If your app sets cookies from one path and then expects them to protect files at a broader path, set Path explicitly to cover everything you need.
IPv6, IP restrictions, and a setting that quietly conflicts with itself
Custom policies support an optional IpAddress condition, restricting the signed URL or cookie to a specific IPv4 address or CIDR range (a shorthand for writing "this whole block of addresses," the way a shop might say "everything on Main Street" instead of listing every house number). Two things go wrong here often enough to call out on their own: IPv6-format addresses aren't supported in this condition at all, and a signed URL or cookie sent from an IPv6 address will be rejected outright if the policy relies on IpAddress. If your distribution has IPv6 enabled and you're also using an IP-restricted custom policy, you have a structural conflict — a viewer connecting over IPv6 will fail the check no matter how correct everything else is. If you need the IP restriction, turn off IPv6 for that distribution.
Trusted key groups vs. trusted signer AWS accounts
Older distributions sometimes still use the original method — a CloudFront key pair tied to the AWS account root user instead of a key group. If your Trusted authorization type reads "Trusted Signer" rather than "Trusted key groups," the Key-Pair-Id in your signed URL must match the Access Key ID of that CloudFront credential exactly, and you can only have up to two active account-level key pairs total, created by signing in as the root user specifically — IAM users are blocked from creating them.
| Signer type | Who can manage it | Practical limit |
|---|---|---|
| Trusted key group (recommended) | Any IAM user with CloudFront key-group permissions, via console or API | Up to 4 key groups per distribution, 5 public keys per key group, 10 key groups per account |
| Trusted signer (AWS account key pair) | Root user only — IAM users cannot create these key pairs | Up to 2 active key pairs per account |
If you're migrating an existing distribution off a trusted signer AWS account and onto a key group, do it gradually: add the new key group as an additional signer, update your application to sign with the new private key, confirm new URLs verify correctly, and only then remove the old trusted signer — don't swap them in one step on a distribution serving live traffic.
Rotating a key pair without breaking URLs that haven't expired yet
Key rotation is a common trigger for a wave of "Unknown Key" errors that appear all at once, because it's easy to remove the old key before every outstanding signed URL has expired. The safe sequence is:
- Create a new key pair and add the new public key to a key group — either the existing one (if it has room) or a new one.
- If you created a new key group, attach it to the distribution as an additional signer. Don't remove the old public key or the old key group yet.
- Update your application to sign new URLs and cookies with the new private key, and confirm those new links verify correctly against the live distribution.
- Wait until the expiration date has passed on every URL or cookie that was signed with the old private key.
- Only then remove the old public key from the key group (or remove the old key group from the distribution, if you used a separate one for the rotation).
Skip step 4 and you'll invalidate every link still in a customer's inbox, browser tab, or cached email — which looks identical, from the outside, to a broken key group setup.
Jake learned this one the expensive way, on a Saturday, which is the one day his shop can't afford a slow morning. He'd rotated the key over the weekend "to be tidy," removing the old public key the same afternoon he added the new one. By Monday, three customers who'd been emailed their repair quotes the previous Thursday couldn't open them, and two called annoyed enough that he nearly lost the jobs. Nothing about the new setup was wrong — the old links were just signed with a key that no longer existed anywhere.
Signed URL or signed cookie — which one fits what you're building
The key group setup underneath both is identical, so choosing between them is purely about how many files you're protecting and how they're delivered, not about the signer.
| Situation | Better fit | Why |
|---|---|---|
| A single file — one invoice link, one report download | Signed URL | One self-contained link; no cookie state to manage. |
| A whole section of a site — a members-only app with many pages and assets | Signed cookie | One authorization grant covers every request in the path, without rewriting every asset URL. |
| A viewer whose client doesn't reliably store cookies (some embedded devices, some download managers) | Signed URL | The credential travels with the link itself, not with the browser's cookie jar. |
When it isn't the signer at all
Once the Key-Pair-Id resolves and the policy decodes cleanly with valid dates and a matching Resource, but you're still getting a 403, the cause has usually moved outside the signing system entirely:
- The origin, not CloudFront, is returning the 403. The "origin" is just the real storage location sitting behind CloudFront — usually an Amazon S3 bucket, the place your files actually live before CloudFront caches and hands them out. If that origin is S3 and you're using Origin Access Control (OAC) — the setting that lets only CloudFront, and nobody browsing S3 directly, read the bucket — check that it's correctly configured and that the bucket policy actually grants it access. An
AccessDeniedwith an S3-styleRequestIdandHostIdin the response body is coming from S3 itself, not from CloudFront's signature check. - The object genuinely doesn't exist, or the case doesn't match. S3 object keys are case-sensitive; requesting
INDEX.HTMLwhen the file isindex.htmlreturns Access Denied, not a friendly 404. - AWS WAF is sitting in front of the distribution and blocking the request before CloudFront's own logic runs. WAF (Web Application Firewall) is a separate AWS service that screens requests for attack patterns before they ever reach your content — think of it as a bouncer standing in front of the actual door CloudFront guards. CloudFront can't distinguish its own 403 from one WAF generated, so check your web ACL rules (WAF's list of block/allow conditions) for a blocked match if signing checks all pass.
- Two CloudFront distributions are chained, with one distribution's origin pointing at another CloudFront distribution. This configuration returns a 403 and isn't recommended.
Building a repeatable test before you touch production again
Once a key group setup starts working, it's worth having a small, repeatable way to check it again the next time something changes — a new teammate edits a behavior, a key gets rotated, or a new environment gets stood up. The AWS CLI's built-in signer is the simplest version of this, because it removes your own signing code from the equation entirely while you're isolating whether the problem is the CloudFront configuration or the application logic:
aws cloudfront sign \ --url "https://your-domain/reports/example.pdf" \ --key-pair-id "YOUR_KEY_PAIR_ID" \ --private-key file://private_key.pem \ --date-less-than 2027-01-01
If a URL generated this way still comes back "Unknown Key," you've confirmed the problem lives entirely in the CloudFront-side configuration — the key group, the behavior, or the distribution's deployment state — and your application's signing code isn't the culprit. If this URL works but your application's own signed URLs still fail, the opposite is true, and it's worth going back to the earlier section on signature errors and confirming your app is using an SDK's signing helper rather than hand-built string formatting.
Reading the request in browser developer tools
When a signed cookie fails after the first page load, open the browser's developer tools, go to the Application (or Storage) tab, and look at the cookies actually stored for the domain. Confirm all three CloudFront cookies are present — the Key-Pair-Id, the signature, and either the Policy or the Expires value — and check their Domain and Path columns against what you expect. It's common to find that only one or two of the three cookies made it through, which points straight back at the Set-Cookie headers your server sent rather than anything on CloudFront's side.
♂️ Jake's Reality Check
"Is there a third-party tool that just handles all of this for me so I don't have to think about key groups at all?"
Not really, and that's by design. Signed URLs and cookies are a CloudFront-native feature, so the key pair, key group, and cache behavior configuration always happens through AWS itself — the console, the CLI, or an SDK. Third-party libraries exist to help generate the signature string in a given programming language, which is genuinely useful, but they don't remove the need to get the key group and cache behavior right first. Skipping that part is exactly what produces the errors in this post.
Before you hand signed links to customers: a quick privacy check
A working signed URL is still just a URL, and URLs leak in ordinary ways — forwarded emails, browser history synced to another device, a screenshot posted somewhere it shouldn't be. Two habits close most of that gap without adding real complexity to the key group setup you've just built:
- Keep the expiry window as short as the use case allows. A one-time download link doesn't need a week of validity; an hour or a day is usually enough, and it shrinks the window during which a forwarded or leaked link still works.
- Reissue rather than reuse. If a customer needs the same report again later, generate a fresh signed URL for that request instead of storing and re-sending the original one indefinitely. It costs nothing extra and means an old, possibly-leaked link quietly stops working on its own.
Quotas that quietly cap a growing setup
These are default account-level quotas. They're adjustable through a service quota increase request if a growing app genuinely needs more, but hitting one unexpectedly looks exactly like a broken setup until you check it.
| Quota | Default |
|---|---|
| Key groups associated with a single distribution | 4 |
| Key groups per AWS account | 10 |
| Public keys per key group | 5 |
| Distributions associated with a single key group | 100 |
| Cache behaviors per distribution | 75 |
If you're trying to add a fifth key group to a distribution and the console or API call fails, this is almost certainly why — and the fix is either consolidating public keys into fewer groups or requesting a quota increase, not re-checking your signing code.
A short checklist for the next time this happens
Jake ended up finding his bug in about ten minutes once he stopped guessing: a colleague had added a second, narrower cache behavior ahead of the one serving his reports, and it didn't have the key group attached. The path pattern matched first, and CloudFront rejected the (perfectly valid) signed URL before it ever reached the behavior with the right trusted signer.
"The thing that got me," he told Ethan afterward, "is that I kept re-signing the URL over and over, assuming I was the one who broke it. I never once looked at the behavior list."
"That's the whole lesson," Ethan said. "The signature is the last thing to check, not the first."
- Read the exact error text in the response body, not just the 403 status.
- Confirm the cache behavior order and that the right one has Restrict Viewer Access on, with the right key group attached.
- Confirm the Key-Pair-Id you're signing with matches a public key that's actually inside that key group.
- Decode the policy and check the statement count, dates, and Resource URL.
- If it's a signed cookie, check the Domain and Path attributes on the Set-Cookie headers.
- If everything above checks out, look at the origin, WAF, and any distribution chaining before assuming the signer is still the problem.
Frequently asked questions
What does "Missing Key-Pair-Id query parameter or cookie value" mean?
It means CloudFront never received a Key-Pair-Id at all — either as a query parameter on a signed URL or as a cookie value on a signed cookie. This happens before any signature check, so the fix is in how the link is generated or transmitted, not in the key group.
Why do I get "Unknown Key" even though my signed URL looks correct?
Because the Key-Pair-Id you signed with isn't in any key group attached to the specific cache behavior handling that request. This is often a mismatch between which behavior is matching the path and which behavior actually has the right key group attached.
Can I use both signed URLs and signed cookies on the same distribution?
Yes. You can require signed URLs for one cache behavior and signed cookies for another, and you can attach different key groups to each behavior if you want separate signing keys per section of the distribution.
How many key groups can I attach to one CloudFront distribution?
Four by default, which can be increased with a service quota request. Each key group can hold up to five public keys.
How many public keys can I put in a single key group?
Up to five per key group by default. If you need more active keys than that, spread them across additional key groups instead, up to the four-key-groups-per-distribution limit.
Do I need the AWS account root user to create a key group?
No. Creating and managing key groups can be done by any IAM user with the right permissions, through the console or the CloudFront API. Only the older, account-level CloudFront key pair method requires signing in as the root user.
What key formats does CloudFront accept for signed URL signing?
An SSH-2 RSA 2048 or ECDSA 256 key pair, in base64-encoded PEM format. If you're signing from .NET or Java, the private key needs to be reformatted first — to XML for .NET, or to DER for Java.
Why does my signed URL work in Postman but fail in the browser?
Check the Resource (base URL) in the policy against the exact URL, protocol, and host the browser actually requests. A tool like Postman (a standalone app developers use to fire off test web requests without opening a browser) may be hitting the CloudFront default domain while your app serves through an alternate domain, or vice versa, and CloudFront treats those as different resources.
Can I use IPv6 addresses with a custom policy's IpAddress condition?
No. The IpAddress condition only supports IPv4 addresses and ranges. If a viewer connects over IPv6 and your policy relies on this condition, the request is rejected. Either disable IPv6 on the distribution or drop the IP restriction.
Why do my signed cookies stop working across subdomains?
Check the Domain attribute on your Set-Cookie response headers. Without an explicit Domain attribute, the cookie only applies to the exact domain that set it, not to subdomains. Setting Domain explicitly extends it to subdomains, but it must match the domain in the URL.
Should I use trusted key groups or trusted signers (AWS account)?
Trusted key groups are the recommended approach for new setups. They can be managed by IAM users instead of only the root user, they support more active keys, and they integrate with IAM permission policies for finer access control.
How do I rotate a CloudFront key pair without breaking live signed URLs?
Add the new public key alongside the old one, switch your signing code to the new private key, confirm new links verify correctly, wait until every previously issued URL or cookie has expired, and only then remove the old public key.
Why does my signed URL fail only after the object's path pattern matches a different cache behavior?
CloudFront evaluates cache behaviors in order and uses the first one whose path pattern matches the request. If a broader or differently configured behavior matches before the one your key group is attached to, your request is evaluated against the wrong behavior's signer settings.
Is there a difference between signed URLs and signed cookies for which files they protect?
A signed URL protects exactly the resource named in its policy. A signed cookie, once set, can cover every request matching the cookie's Domain and Path attributes, which makes it a better fit for protecting many files or pages at once rather than a single link.
My policy statement decodes fine but I still get 403 — what else could it be?
Look outside the signing system: check whether the origin itself (for example, an S3 bucket with Origin Access Control) is returning the denial, whether AWS WAF is blocking the request in front of CloudFront, or whether the object name's case matches exactly what's stored at the origin.
Do signed URLs/cookies work through a CloudFront distribution placed in front of another CloudFront distribution?
Chaining two CloudFront distributions in a request path isn't recommended and results in a 403 error. Route directly to a single distribution instead.
π ALSO READ
Hitting other CloudFront 403s or S3 access errors? These fixes save hours of debugging:
- π CloudFront 403 "request could not be satisfied"? The cache behavior misconfig that causes it
- π CloudFront 403 from S3 origin? The OAC (Origin Access Control) fix nobody mentions
- π S3 AccessDenied on GetObject? Every fix in one place (it's usually one of these 5)
- π S3 NoSuchBucket but bucket exists? The region and naming issues behind this confusing error
- π CloudFront 502/504 errors? Origin problems explained — the other side of private content debugging
⚡ Bookmark these — they cover the full CloudFront + S3 private-content error cluster.
Revision note. Written September 2026, covering CloudFront's current trusted key group and signed URL/cookie behavior, including the console-based key pair, public key, and key group workflow. This will need a look whenever AWS changes the console layout for key groups or the format requirements for signing keys. If you're staring at a 403 right now with a report or a link a customer is waiting on, take a breath — the fix is almost always smaller than it feels in the moment.