Your document and its signature do not travel together
A signed artefact published on the web is at least two HTTP resources. The verifier downloads them separately, and nothing guarantees they come from the same generation. When they do not, verification fails exactly as it would on a forgery. Here is the mechanism, what it breaks, and the three ways to close it.
You publish a signed document. The reader runs the command you printed, and it answers:
message imprint mismatch
Verification: FAILED
That answer has two possible causes. One is that someone modified your document. The other is that they downloaded yesterday’s document and today’s token.
Nothing in the message tells them apart.
Two files, two resources, two lifetimes
A signed artefact on the web is never one file. It is at least two independent HTTP resources: the document, and the detached signature next to it. Often three, when a timestamp token is added. In our case, five: a report, its signature, and three date attestations.
The verifier fetches them with separate requests. Each one goes through its own browser cache, its own proxy, its own CDN, and each one comes out with its own freshness. Nothing in the protocol treats them as a set. There is no “fetch me these five files as they were at the same instant”.
It is obvious once said. It is not obvious at all when you publish.
What HTTP does when you tell it nothing
If you set no Cache-Control, the cache does not give up. It guesses. RFC 9111, section 4.2.2, says exactly this:
A cache MAY assign a heuristic expiration time when an explicit time is not specified, employing algorithms that use other field values (such as the Last-Modified time) to estimate a plausible expiration time.
And further on:
If the response has a Last-Modified header field, caches are encouraged to use a heuristic expiration value that is no more than some fraction of the interval since that time. A typical setting of this fraction might be 10%.
Ten per cent of the document’s age. A report published ten days ago is therefore considered fresh for a whole day, without anyone asking the server anything. A report published three months ago, for more than a week.
And it is not a setting you chose: it is what happens in the absence of a setting. Silence is not neutral.
Why this failure is worse than the others
A blog post served from cache is a nuisance. Here, three things combine.
The document carries the fingerprint of its own content. That is the principle: the report prints the fingerprint of its findings, and that is what gets compared with the token. A stale document carries a stale fingerprint.
And the reader does not hold both files in the same state. This is where it is easy to get it wrong, and we got it wrong while writing this paragraph: you imagine the resources have different freshness lifetimes. On our site they have exactly the same one — the deployment gives them the same Last-Modified, so the same heuristic window, to the second.
The asymmetry is elsewhere, and it is behavioural: the reader has already visited the page, so it is in their cache. They have never downloaded the token, so they do not have it. The first request is served from cache, the second goes to the server. Two generations, simply because people read a page several times and check a signature once.
And the error message is the same as for a forgery. That is the point. In last week’s post, we explained why openssl ts -verify conflates two situations that have nothing in common — a certificate chain it cannot build, and a token that attests a different document. Here is a third one, which lands in the same bucket: two perfectly authentic files, but not from the same generation.
The reader sees FAILED. They conclude what anyone would conclude.
Measured on our own site
We found this on our own site, and how it happened is worth telling: a reader reported that the published inventory still showed a version we had replaced. The server was returning the new one — curl showed it in one line.
The temptation is to answer “clear your cache”. It is true, and it is the wrong answer: the cause was on our side. Our signed artefacts carried no Cache-Control at all, only a Last-Modified. The heuristic was doing exactly what the RFC describes.
Nobody was harmed — the gap was in a version number, not in the fingerprint, so verification still passed. But that was luck, not design: had the findings moved, a careful reader would have concluded our seal was broken.
This is not a blog problem
The mechanism depends on nothing specific to us. Wherever a signed artefact is published with its signature next to it, the same desynchronisation is possible:
- an SBOM and its signature, in a GitHub release or an artefact registry;
- a provenance attestation — SLSA, in-toto — served next to the image it describes;
- a sigstore bundle, whose
.sigstoreis a second request; - a release manifest and its
SHA256SUMS.asc; - any RFC 3161 token placed next to the document it dates.
And it gets worse with every layer you stack. A CDN keeps its own copy, with its own lifetime, and does not necessarily purge all five files at the same instant. A package mirror resyncs in batches. The more intermediaries, the longer the window during which your resources come from different generations.
Three ways to close it, and what they cost
1. Forbid use without asking. Cache-Control: no-cache on the family of URLs that carry signed artefacts. The name is misleading: it does not forbid storing, only serving without revalidation. The real cost is a 304 Not Modified — a few hundred bytes — and the benefit is that a reader can no longer compare two generations.
# what the server says now, on /audit/*
cache-control: no-cache
# and what revalidation costs
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" …
304
It is the cheapest and most immediate remedy. It is the one we applied.
2. Put the fingerprint in the URL. A file named report-8ecb829c.html cannot be stale: if the content changes, the name changes, so the address changes. A cache that serves you the old URL serves you the old document — which is correct, and verifiable, because its fingerprint is in its name.
It is sturdier than point 1, and more expensive: links move with every publication, and you have to keep old versions reachable or accept dead links. Keep it for artefacts you archive rather than those you republish.
3. Print the fingerprint in the document. This one does not replace the other two, it makes them diagnosable. If the report displays the fingerprint it covers, the verifier can compare three values instead of two: the one the document announces, the one the token attests, and the one recomputed.
And the combination finally tells the cases apart:
| what you observe | what it is |
|---|---|
| the document announces A, the token attests A, the recomputation gives A | all is well |
| the document announces A, the token attests B | the two files disagree. Almost always a stale copy — reload everything, without cache, before considering anything else |
| the document announces A, the token attests A, the recomputation gives B | now someone has modified the document |
Three rows that turn a mute FAILED into three separate diagnoses.
What it says about a verification tool
It is the same lesson as for the four states of a timestamp, and it generalises: a tool that verifies must tell the reasons for failing apart, not just report the failure.
“These two files are not from the same generation” and “this document has been modified” call for opposite actions. The first is fixed by reloading. The second is an incident. Confusing them means either raising an alert for a cache problem or — far worse — filing a real forgery under network oddities and moving on.
That is also why you should test the verification command from a machine that is not yours, and preferably from an empty container. The machine that produced the artefact has all the right versions locally; it is precisely the one place where the bug is invisible.
Good practices
- Treat the document, its signature and its token as a set, since HTTP never will. If you take only one action, take this one.
- Set an explicit
Cache-Controlon signed artefacts. Setting nothing is not neutral: the RFC allows the cache to guess, and it does. - Print the fingerprint in the document itself. It costs one line and lets you tell a stale copy from a forgery.
- Verify from somewhere else. An empty container, another machine, another network. The origin does not reproduce the bug it causes.
- If you archive rather than republish, put the fingerprint in the file name. A content-addressed URL cannot serve a stale version.
Watch out for
no-cacheandno-storedo not say the same thing. The first allows keeping and requires revalidating; the second forbids keeping. For a public artefact the first is enough, and the second wastes bandwidth for nothing.- Your CDN has its own policy, which can outlive the origin’s. Check on the wire, from outside, not in your configuration file.
- Purging a cache file by file reopens the window: during the purge, the resources come from different generations. Purge the whole family at once, or let revalidation do the work.
- A verifier that downloads its files a few minutes apart can land in the middle of a deployment. It is rare, it is real, and it is yet another failure that looks like a forgery.