ka
Khairul Azharsenior software engineer
All writing
TechnicalAug 16, 2026 · 4 min

Your Cache Header Is Lying to You

I published a post and my own admin said DRAFT for an hour. The culprit: a global Cache-Control header commented “prevent caching of sensitive content” that did the exact opposite, plus next.config headers overriding route handlers, and hard refreshes that save nothing.

KA
Khai
Senior Software Engineer
Your Cache Header Is Lying to You

I published a blog post, opened my admin panel, and it said DRAFT. The database said published. The live site showed the article. The admin insisted: draft. For a solid minute I doubted which environment I was even looking at.

The bug turned out to be one line in next.config.ts, sitting under the most ironic comment I have ever shipped:

// Prevent caching of sensitive content
{ key: "Cache-Control", value: "public, max-age=3600, must-revalidate" }

A header whose stated job was to prevent caching of sensitive content was instructing every browser to cache everything, including authenticated admin API responses, for an hour. My browser had stored the pre-publish response, and it was serving that snapshot back to me, fully within its rights, for the next sixty minutes.

Three traps stacked on top of each other here. Each one is worth knowing on its own.


Trap 1: a global header applies to everything, including what you forgot about

The rule was defined with a catch-all source, so it hit static pages (fine), public content (fine), and every /api/* response (not fine). API responses are dynamic data by definition: an admin list, a stats endpoint, a search result. public, max-age=3600 on those means every consumer sees up-to-hour-old data, and public specifically invites shared caches to store responses that were produced for an authenticated user.

Nobody decided the admin should cache for an hour. It fell out of a rule written for pages, inherited by routes nobody was thinking about.


Trap 2: the config header overrides your route handler

My first fix was the obvious one. Set the header in the route handler:

return NextResponse.json(data, {
  headers: { "Cache-Control": "no-store" },
});

Built it, started a production server, probed it with curl. The client received public, max-age=3600. In Next.js, headers declared in next.config are applied on the way out and win over what your handler sets. My fix compiled, deployed, and did nothing. I only know because the probe measured the actual response instead of trusting the code.

The working fix lives in the same config file that caused the bug: a later-matching rule, because when two rules set the same header, the last match wins.

{
  source: "/api/:path*",
  headers: [{ key: "Cache-Control", value: "no-store" }],
}

Trap 3: hard refresh doesn’t save you, and the poison outlives the fix

Here is the part that makes this bug feel supernatural: deploying the fix clears nothing. The stale response is already stored client-side with an hour of validity, and the browser will keep serving it until it expires. Worse, a hard refresh does not reliably bypass the HTTP cache for a page’s own fetch() calls. It bypasses the document and its subresources, but the JavaScript your page runs afterwards happily reads the poisoned entry.

So the fixed code shipped, and the admin still said DRAFT. If you ever need to surgically evict one of these, the same URL fetched with cache: "reload" hits the network and replaces the stored entry:

await fetch("/api/posts?", { cache: "reload" });

After that, the page reads fresh data. And with no-store on every response going forward, there is nothing left to go stale.


The bug behind the bug

While tracing why the admin fetch was cacheable at all, I looked at the endpoint itself and found something worse. GET /api/posts had no authentication. Neither did GET /api/posts/[id]. The write operations were locked down; the reads were open. Anyone could list my draft posts with ?status=draft.

It is a classic shape: auth gets added where the danger feels obvious (writes, deletes) and skipped where it does not (reads). But reads are where the data leaks. If an endpoint exists only to serve your admin UI, it needs a session check. Full stop.


The checklist I ended up with

  • No Cache-Control: public on anything that varies by session. If it is authenticated, it is no-store, or at minimum private.

  • Global header rules get an explicit carve-out for /api/*. Dynamic data is not a page.

  • Never trust a header you set in code until you have seen it on the wire. One curl against a production build settles what the framework actually emits.

  • Auth-gate reads, not just writes. List endpoints leak more than delete endpoints ever will.

  • When users report “it still shows the old thing” after a fix: think client-side cache with remaining TTL before you think broken deploy.

Total damage: one confused hour, two real bugs, and a comment in my config that will never live down what it did.

Next essay

Day Job by Day, Products by Night

From nine to six I am a senior mobile engineer on a two person team. Some nights I am a one person software company with two live products. What each life fixes about the other, and the rules that keep the whole thing sane.

ka

I design and ship resilient mobile platforms and the backends that keep them honest.

© 2026 · Privacy · v4.2.0 · commit 8a3f12c