CloudFront to Vercel: The Cache Header Mismatch That Cost Us a Weekend
We fronted a Vercel app with CloudFront to satisfy a compliance requirement. Two weeks later, stale checkouts and missing Set-Cookie headers taught us how differently these two CDNs think about caching.
A compliance clause forced us to route a client's production traffic through AWS CloudFront before it hit their Vercel deployment. On paper: trivial. In practice: two weeks of intermittent stale checkouts, vanished session cookies, and a Slack thread that got long enough to deserve its own retrospective.
If you're about to put CloudFront (or any origin CDN) in front of Vercel, read this first. The two systems have different opinions about what a cache header means, and nobody warns you until users are calling support.
Why we did it in the first place
The client's parent org had a security policy that required all inbound traffic to pass through an AWS WAF ruleset owned by their central platform team. That WAF was attached to a CloudFront distribution. Vercel has its own WAF now, but "use ours" wasn't a negotiation we were going to win.
So the topology became:
User -> Route53 -> CloudFront (WAF) -> Vercel Edge -> Serverless Functions
Two CDNs stacked. Two cache layers. Two sets of header semantics. What could go wrong.
The initial setup that looked fine
We pointed CloudFront's origin at the Vercel deployment's *.vercel.app hostname, set the host header override, forwarded the Authorization and Cookie headers, and used the AWS-managed CachingOptimized policy for static assets and CachingDisabled for everything else. Smoke tests passed. Lighthouse looked good. We shipped it on a Thursday.
The first symptom: stale product pages
By Saturday, support had three tickets about product pages showing sold-out items as available. The Vercel deployment was returning fresh data — we confirmed by hitting the *.vercel.app origin directly. CloudFront was serving a cached copy that was about six hours old.
The culprit was subtle. Our Next.js app was setting:
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Vercel's edge respects s-maxage and stale-while-revalidate beautifully. CloudFront respects s-maxage. CloudFront does not honor stale-while-revalidate as a revalidation directive the way Vercel does — it treats the response as cacheable for the s-maxage window, then falls back to its own min/max TTL configuration for anything after that.
The AWS-managed CachingOptimized policy has a default TTL of 24 hours and a max TTL of one year. When our s-maxage=60 expired, CloudFront wasn't revalidating on every request the way we assumed. It was applying its own TTL logic on top.
The fix, and why it's annoying
We wrote a custom CloudFront cache policy with:
- Min TTL: 0
- Default TTL: 0
- Max TTL: 31536000
- Origin cache headers: honored
That pushes all TTL decisions to the origin. Combined with an explicit Cache-Control on every route in the Next.js app, behavior became predictable. But it means you can no longer rely on the managed policies — every team touching the distribution has to know that the origin is authoritative.
The second symptom: disappearing sessions
About a week in, users started getting logged out mid-checkout. Not all users. Not consistently. The kind of bug that makes you question your career.
CloudFront was stripping Set-Cookie headers on cached responses. This is documented, but it's the kind of documentation you only read after the incident. If a response is cacheable and you haven't explicitly included Set-Cookie in the response headers policy, CloudFront drops it — because caching a response with a user-specific cookie would be a catastrophic cross-user leak.
The problem was that our auth callback route was returning Cache-Control: no-store, which we assumed made it uncacheable and therefore safe. CloudFront's behavior here is defensive: even for uncacheable responses, Set-Cookie isn't forwarded unless the response headers policy allows it, in some configurations. We were tripping over an interaction between the origin request policy and the response headers policy that we hadn't fully modeled.
What actually worked
We split the distribution into two behaviors:
# Terraform sketch — not the full config
ordered_cache_behavior {
path_pattern = "/api/auth/*"
cache_policy_id = aws_cloudfront_cache_policy.no_cache.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.all_viewer.id
response_headers_policy_id = aws_cloudfront_response_headers_policy.pass_cookies.id
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
}
default_cache_behavior {
cache_policy_id = aws_cloudfront_cache_policy.origin_authoritative.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.minimal.id
viewer_protocol_policy = "redirect-to-https"
}
The all_viewer origin request policy forwards everything to Vercel. The response headers policy explicitly allows Set-Cookie through. For all other traffic, we forward the minimum — no cookies, no auth headers — so those responses stay genuinely cacheable and shared across users.
That separation is the mental model we should have started with: any behavior that touches user identity is a completely different beast from a behavior that serves shared content. Vercel handles this distinction implicitly through its routing and cache tags. CloudFront makes you spell it out.
The third symptom: Vercel's analytics went dark
Once CloudFront was in the path, Vercel's Web Analytics stopped attributing traffic correctly. The requests hitting Vercel's edge all came from a small pool of CloudFront IPs, so geographic and device data collapsed.
The fix was forwarding CloudFront-Viewer-Country, CloudFront-Viewer-City, and the real User-Agent as custom headers, then having a small middleware layer on Vercel read those instead of the request's apparent origin. Not hard, just another thing nobody documents in the "put a CDN in front of your app" tutorial because that tutorial doesn't exist.
The bill nobody warned us about
CloudFront to Vercel is egress from AWS to a third party. Every request pays AWS egress rates on the response body, then pays again on Vercel's bandwidth meter. For a static-heavy site this can double your CDN costs compared to running on Vercel alone.
In our case, roughly 60% of the traffic was static assets we could have served straight from CloudFront by uploading builds to S3, bypassing Vercel entirely for those paths. We didn't do that — the client wanted a single deploy pipeline — but it's the tradeoff to think about before you sign the architecture off.
What we'd tell the next team
A few rules we now enforce on any project stacking CloudFront in front of Vercel (or in front of any modern platform CDN, honestly):
- Origin is authoritative for TTL. Custom cache policy, min/default TTL of zero, max TTL high. Never use
CachingOptimizedfor dynamic origins. - Split behaviors by identity boundary. Auth routes and any route that sets cookies get their own behavior with
no-cachepolicy and an explicit response headers policy that allowsSet-Cookie. - Forward viewer metadata explicitly. If you care about analytics or geo-routing, plumb the
CloudFront-Viewer-*headers through to your app. - Model the cost twice. AWS egress plus Vercel bandwidth is not the same math as Vercel alone. Get a real traffic sample from the first week and reforecast.
- Test with real cookies, not curl. Half our bugs would have surfaced in a Playwright suite that logged in, waited five minutes, and tried to check out. Curl-based smoke tests missed all of them.
Where we'd start
If you're staring down this exact requirement, start with a spike: put CloudFront in front of a staging Vercel deployment for a week with realistic synthetic traffic, and instrument both layers. Use the Vercel logs to see what's reaching origin, and CloudFront's real-time logs to see what's being served from cache. The gap between those two numbers is where your bugs live.
And if the compliance requirement is negotiable — check whether Vercel's own WAF and BYOC options (their enterprise networking docs cover this) satisfy the actual control the security team cares about. Removing a CDN layer is almost always cheaper than debugging one.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

OpenTelemetry Sampling in Production: The Config That Saved Our Trace Bill
Head sampling threw away the traces we needed. Tail sampling blew up our collector memory. Here's the sampling config we landed on after six months in production.
Vercel Edge Middleware Latency: What We Measured When We Moved Auth to the Edge
We moved auth checks from a Node API route to Vercel Edge Middleware expecting free speed. Some routes got faster, some got slower, and the bill moved in ways we didn't predict.

Terraform State Corruption at 3 AM: How We Recovered and What We Changed
A stale lock, a partial apply, and a state file that no longer matched reality. Here's the recovery playbook we now hand every new engineer — and the guardrails that make sure we never do it live again.
