Cache-Control is a signed header — and that broke every R2 upload

Cache-Control is a signed header — and that broke every R2 upload
A few weeks ago we noticed something embarrassing on one of our Next.js apps hosted on Cloudflare Pages: chapter images on mobile were re-fetching on every session. The CDN tab in DevTools told the story — every R2 image came back without a Cache-Control header, and Chrome was dutifully revalidating each one. We thought we'd fix it by setting Cache-Control on the upload path. The first attempt broke every upload with 403 SignatureDoesNotMatch. This is what we learned.
The problem
Our reader app stores chapter images and avatars on Cloudflare R2 and serves them through R2's public bucket URL — pub-*.r2.dev. That URL pattern is convenient (no DNS, no Workers in front), but it has a real cost: there's no Cloudflare zone attached, which means there's no place to set CDN cache rules. Whatever Cache-Control R2 sends on GET, that's what the browser sees.
By default, R2 sends nothing. Even for objects that haven't changed in months, browsers treated each request as freshness-unknown and issued a conditional GET. The 304 round-trip is "fast" — except on mobile it's still an RTT, and we had a chapter page with twelve images per scroll.
The desired behavior was straightforward: bake Cache-Control: public, max-age=31536000, immutable into the object metadata at upload time, so R2 returns it forever. R2 keys are content-addressed in our scheme (slug-prefixed filenames, never overwritten), so immutable is honest — a re-upload lands at a different key.
Why it happened
Our upload flow uses presigned PUT URLs signed with AWS SigV4 via aws4fetch. Server signs a presigned URL; client PUTs the file. Simple.
Our first attempt was to set Cache-Control only on the client XHR. That broke nothing — but it also did nothing. R2 happily returned 200 and stored the object without the header. R2 only persists Cache-Control if the server signs it as part of the request.
So we moved the header up: include Cache-Control in the signed request on the server side. Then R2 stores it. Then GETs return it. That's the contract.
// lib/r2.ts — presignR2Put
const signed = await env.client.sign(
new Request(s3Url, {
method: "PUT",
headers: {
"Content-Type": input.contentType,
"Cache-Control": R2_CACHE_CONTROL,
},
}),
{ aws: { signQuery: true, allHeaders: true } },
);
That worked — for exactly as long as it took the client to PUT without the matching header. Then every upload started failing with:
403 Forbidden
<Error><Code>SignatureDoesNotMatch</Code>…</Error>
What we tried first
The instinctive read was "Cache-Control isn't supposed to be signed — strip it from the signature." That's wrong, and the aws4fetch source tells you why. Its UNSIGNABLE_HEADERS set is short and specific:
authorization, content-type, content-length, user-agent,
presigned-expires, expect, x-amzn-trace-id, range, connection
Cache-Control isn't on that list. It's a normal request header from SigV4's point of view. If you put it in the request before signing, aws4fetch hashes it into SignedHeaders and embeds the name in the presigned URL's X-Amz-SignedHeaders query parameter. R2 then re-derives the signature on the way in, including whatever Cache-Control value the client actually sent — and compares.
In other words: once we signed Cache-Control, the client had to send the same value, byte-for-byte, or R2 would refuse the upload.
The fix
Two changes — one obvious, one that the senior code review made us do twice.
The obvious one was telling the client to send the same header the server signed:
xhr.open("PUT", presign.upload_url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.setRequestHeader("Cache-Control", "public, max-age=31536000, immutable");
xhr.send(file);
That worked. Uploads succeeded; R2 returned the header on GET; mobile stopped revalidating.
The non-obvious change was where to put the literal. We had "public, max-age=31536000, immutable" in two places — lib/r2.ts (server) and ImageDropzone.tsx (client). A drift between those two strings would silently break every upload. So we pulled the constant out:
// lib/r2-constants.ts
/**
* Shared between presignR2Put (server signing) and the XHR upload
* (client must send byte-exact value or R2 rejects).
* 1y + immutable because R2 keys are content-addressed —
* a re-upload lands at a different key.
*/
export const R2_CACHE_CONTROL = "public, max-age=31536000, immutable";
The reason this lives in its own file, not in lib/r2.ts, is that lib/r2.ts imports aws4fetch. ImageDropzone.tsx is a Client Component. Importing the constant from lib/r2.ts works in source — but pulls AwsClient into the browser bundle, which adds ~20 KB of base64-and-crypto code that the client doesn't need. The separate constants file lets us tree-shake cleanly. We grep .next/static/chunks/ for AwsClient after each build; it shouldn't appear.
// Server
import { R2_CACHE_CONTROL } from "@/lib/r2-constants";
// Client (ImageDropzone)
import { R2_CACHE_CONTROL } from "@/lib/r2-constants";
One constant, one source of truth, no bundle bloat.
Before and after
| Surface | Before | After |
|---|---|---|
pub-*.r2.dev GET response |
no Cache-Control |
public, max-age=31536000, immutable |
| Mobile chapter re-entry | every image revalidated (RTT × N) | served from disk cache |
lib/r2.ts PUT signature |
Content-Type only | Content-Type + Cache-Control signed |
| Browser bundle (Client Component) | would import aws4fetch if we re-used lib/r2.ts |
imports a 1-line constants module instead |
We also swept the wider unoptimized flag off the most-fetched Next.js <Image> surfaces — Lightbox, avatars, comments — so Vercel's image optimizer can serve AVIF on top of the now-cacheable origin. Same idea, different layer: cache what doesn't change, transform what does.
What we learned
- Read your signing library's
UNSIGNABLE_HEADERS. If a header isn't on that list, it's signed — and a client/server mismatch becomes a runtime 403, not a compile error. The list for SigV4 is short. Skim it before assuming. - A constant in two files is a future outage. The senior code review (A-, M-1) flagged this before it shipped. The drift would have been silent — until someone tightened
max-ageto 90 days in only one place and every admin upload broke. - Treat
pub-*.r2.devlike a CDN you can't configure. Anything you want in the response —Cache-Control,Content-Disposition, customx-*headers — has to be set at PUT time and signed. If you need post-hoc control, you need a Worker or a Cloudflare zone in front. Plan for that before launch, not after. - Document the upload contract for every downstream client. A few hours after this shipped, a different team that consumed the same presign endpoint started seeing
SignatureDoesNotMatchon every PUT. The fix was one line. The cost was a triage session that wouldn't have happened if the required-headers table had been there from day one.
What's next
We have ~6 months of R2 objects uploaded before this change — they all lack Cache-Control. We'll backfill via S3 CopyObject with MetadataDirective: REPLACE to overwrite metadata in place, no re-upload needed. That's a one-shot script before our next release.
We're also looking at whether Content-Disposition and a future x-eodin-source header want the same treatment. The cost of getting them wrong is now well-understood: 403 on every upload until the client catches up. Worth it for caching. Worth less for headers we don't actually need.
Try Eodin
Eodin builds small products that solve specific problems. See what we're working on.
Frequently Asked Questions
Why did Cloudflare R2 uploads start failing with a 403 SignatureDoesNotMatch error?
Uploads failed because the Cache-Control header was included in the signed request but the client did not send the exact same header value. Since Cache-Control is a signed header in AWS SigV4, any mismatch between the signed value and the client-sent value causes R2 to reject the upload with a 403 error.
How can I ensure Cache-Control headers are correctly applied to objects uploaded to Cloudflare R2?
You must include the Cache-Control header in the server-side signed PUT request and ensure the client sends the identical Cache-Control header value when uploading. Using a shared constant for the header value on both server and client prevents mismatches that cause signature errors.
Why does Cloudflare R2 not set Cache-Control headers by default on GET responses?
R2's public bucket URLs (pub-*.r2.dev) have no Cloudflare zone attached, so no CDN cache rules can be set. By default, R2 does not send Cache-Control headers unless they are explicitly set in the object's metadata at upload time.
What is the impact of missing Cache-Control headers on mobile devices when fetching images from R2?
Without Cache-Control headers, browsers treat objects as freshness-unknown and revalidate them on every session, causing unnecessary network round-trips. On mobile, this results in slower page loads and repeated conditional GET requests for each image.
How can I avoid bundle bloat when sharing Cache-Control constants between server and client code?
Place the Cache-Control header value in a separate constants file imported by both server and client. This prevents importing heavy dependencies like aws4fetch into the client bundle, keeping the client-side code lightweight and tree-shakeable.
Continue reading

How a UK paracetamol bottle changed Tempy’s dose UI
Our dose calculator quietly assumed 160 mg/5 mL acetaminophen. UK paracetamol bottles ship at 250 mg/5 mL. Here’s why we surfaced the assumption.

The removeConsole: true bug that hid every other bug
A Linkgo Railway memory pass surfaced a one-line next.config.js bug that had been silently stripping every console.error in production — and the three reliability fixes that surfaced once we could read the logs.

What happens to a connection pool when you fork a Celery worker (spoiler: nothing good)
Our admin dashboard started hitting Prisma P2037. pg_stat_activity showed 96 idle connections and 1 active. Here's how two SQLAlchemy engines, default pool settings, and a fork-unsafe Celery worker added up to 150 theoretical connections from one container — and the three-change fix.