The stale Excel export was Cloudflare, not Excel
I edited some data in an app I maintain, downloaded the Excel export again, and the file showed the state before my edit. On another machine the same download showed the current state.
My first suspect was the change I had made to downloads the day before, when Safari refused to save a file. It wasn’t that. The problem predates it.
Same URL, same edge, same file
The app sits behind Cloudflare. None of its 17 export routes sent a Cache-Control header, which I had never thought about because every one of them requires a login.
Cloudflare doesn’t know that. It caches by file extension by default, and .xlsx and .csv are both on the default list. So an authenticated 200 for an export was cached at the edge, keyed on the URL. Same person, same month, same URL, same Cloudflare location: stale file. Another machine reaching another location: fresh file.
The headers showed the extension rule was live. An anonymous request for an export URL came back 401 with cf-cache-status: BYPASS, which means the URL was eligible for caching and this particular response was not cached. /login came back DYNAMIC, which means the URL was never eligible at all.
If a response is private, say so in the response. The CDN in front of you decides what is cacheable from the URL, and it cannot see your login check.
The part I can’t prove
A cached 200 is served before the request ever reaches my app, so a cached export may have been servable to a logged-out request for the same URL. These files hold personal data. I have not confirmed it either way, so I am writing it down as a possibility and not as something that happened. It was reason enough to fix it the same day.
The fix is in one place
Seventeen routes means seventeen chances to forget. The app already had one after_request hook that owns its cache headers, so the rule went there. In outline:
@app.after_request
def downloads_are_never_cached(response):
if "attachment" in response.headers.get("Content-Disposition", ""):
response.headers["Cache-Control"] = "private, no-store, no-cache, max-age=0"
return response
Any response that is a download gets stamped, so an export written next year can’t forget.
While I was there, the download filenames got seconds and a short nonce. Two exports in the same minute used to collide, and the browser would save the second as “(1)”, which is its own small way of opening the wrong file.
How to check yours
Request one of your own download URLs twice and read the response headers:
curl -sI -H "Cookie: <your session>" https://example.com/export/report.xlsx | grep -i "cf-cache-status\|cache-control"
HIT on a file that needs a login is the bad answer. DYNAMIC or BYPASS with a private, no-store beside it is the good one. If your URLs end in an extension Cloudflare likes and your responses say nothing about caching, the edge is making the decision for you.
