Not long ago, we hit a problem that seemed almost unbelievable at first: customers opening our cashier application suddenly saw error pages instead of the app itself.
No JavaScript bundles. No CSS. No index.html. Just a 501 error cached at the edge.
This wasn't a random outage. It was a cache poisoning attack. An attacker found a way to manipulate request headers so that AWS CloudFront cached an error response and then served it back to all subsequent users.
Obviously, our initial thought was that AWS infrastructure would, by default, prevent anything that might lead to this sort of scenario, but we were naive.
As frontend engineers, we're often focused on building pipelines, performance budgets, and optimising bundles. But when your CDN cache turns against you, the whole frontend collapses fast. In this article, I'll walk you through:
- What cache poisoning is and how it impacted our users
- The business consequences of poisoned caches
- The alternatives we considered (WAF, CloudFront Functions, Origin/Response Policies)
- Why we ultimately chose CloudFront Functions
- Practical code and patterns you can reuse in your own setup
Along the way, I'll include diagrams to illustrate the flow, since it's often easier to see how these attacks propagate visually.
What is Cache Poisoning?
Cache poisoning is when a malicious request tricks your CDN into storing the wrong content under a valid cache key. The following user who comes along gets the poisoned content instead of the real one.
Think of it like a library: someone sneaks in and replaces the label on a box of JavaScript code with a fake one. Now, every reader who checks out that box gets garbage instead of the book they needed.
In our case, here's how it played out:
- Attacker sends a request with a custom, unexpected header.
- S3 (our origin) doesn't know what to do with it → returns a 501 Not Implemented.
- CloudFront caches that 501 response under the standard cache key.
- Subsequent users request the same JS bundle, but instead receive the cached error.

The blast radius was huge: a poisoned index.html at just one edge location meant a whole region of customers could no longer transact.
Why This Matters for Frontend Engineers
From a business perspective, even a single poisoned entry was catastrophic:
- Functional impact: users couldn't deposit or withdraw because the cashier app failed to load.
- Commercial impact: failed transactions → lost revenue → abandoned sessions.
- Trust impact: customers hit support in frustration, and trust erodes fast when money is involved.
Even worse, cache poisoning is hard to reproduce locally. It manifests in specific edge nodes, which makes debugging painful. For the on-call engineer, this meant hours of trying to piece together logs that didn't look the same in staging.
Alternative Solutions
When tackling this, we evaluated three AWS-native approaches to defense:
1. AWS WAF (Web Application Firewall)
WAF is like a shield in front of CloudFront. It can filter malicious requests, block suspicious headers, and even apply rate limits.
- Pros: powerful rule sets, managed updates, reusable across distributions.
- Cons: adds measurable latency and cost, is complex to maintain, and, most importantly, it doesn't normalise the cache key itself.

2. CloudFront Functions (Viewer Request / Response)
CloudFront Functions run tiny bits of JavaScript at the edge, in microseconds. They let you strip unwanted headers, normalise requests, and ensure errors are never cached.
- Pros: deterministic control, low latency, simple deployment.
- Cons: limited runtime and features, need careful rollout.
[Insert Diagram: CloudFront Function normalising requests before caching]

3. Origin Request Policy & Response Headers Policy
These are often overlooked but powerful tools:
- Origin Request Policy lets you explicitly choose which headers, cookies, and query strings get forwarded to your origin. Filtering here ensures the origin only sees what you trust.
- Response Headers Policy can enforce Cache-Control and no-store headers on error responses, reducing the risk of poisoned entries persisting.
- Pros: no code required, AWS-managed, complements cache safety.
- Cons: they operate at the origin boundary. By the time the request reaches CloudFront, it may already have cached an unwanted variant. They're protective, but not sufficient on their own.

Trade-offs
Here's how the options stack up:

Conclusion: WAF is a shield, policies are good hygiene, but CloudFront Functions give you the surgical control needed to prevent poisoning at the source.
Our Final Approach
We chose CloudFront Functions for their precision and speed. Our implementation included:
- Allowlisting allowed request headers (dropping all others).
- Normalising cache keys by stripping unnecessary query params.
- Preventing error responses from being cached (Cache-Control: no-store).
- Adding observability markers for debugging.

Here's a simplified snippet:
https://gist.github.com/EduardoAC/0411858aa6cbbc180c3ab01926152305
This function guarantees that only the headers we trust shape the cache key, removing attacker-controlled variants from the equation.
Key Takeaways for Frontend Engineers
- Don't assume your CDN cache key is safe by default: normalise aggressively.
- Layer your defences: WAF for broad classes of attacks, Functions for surgical control, Policies for guardrails.
- Prioritise observability: cache poisoning is hard to debug without visibility into what's being dropped or cached.
- Think in terms of user impact: every poisoned entry isn't just a technical glitch, it's a broken user journey.
Conclusion
Cache poisoning in CloudFront was a wake-up call: our beautifully optimised frontend bundles were useless if the CDN itself served poisoned errors.
By adopting CloudFront Functions, with WAF and policies as supporting layers, we built a defence-in-depth strategy that was lightweight, fast, and reliable.
If you're a frontend engineer working with AWS, even if CDN details feel far from your daily bundle size battles, I encourage you to audit your cache key setup. You might be surprised how much trust you're placing in uncontrolled request headers.