Skip to content

Blog ·

A badge that set a cookie on every page

One image in the footer cost twenty two Lighthouse points across the site. The fix was not removing it, and the interesting part is what a proxy must refuse.

By Provibr

The best practices score of our marketing site was 78 on every page. Not a page, every page: the homepage, the pricing page, the documentation, all of it, uniformly. Uniform scores are a good sign, because they mean the cause is in something every page shares.

It was one image tag in the footer. A launch listing badge, loaded straight from the platform that issues it, because that is how a badge like that is meant to be embedded and because the number on it is supposed to be live.

What an image tag can do

The badge is served from an API host that sits behind Cloudflare, and Cloudflare answers requests with its bot management cookie: __cf_bm, SameSite=None, Secure, scoped to the badge issuer's domain. That is not a decision by the site whose badge it is. It is what a CDN does, on an SVG, on every page load, for every visitor.

Two Lighthouse audits react to that. The third party cookie audit is weighted five out of the twenty eight weights in the category, and an inspector issue is weighted one. Six weights lost on an image. Across the thirty eight measurements of the previous round, that was the entire gap between 78 and 100.

The third consequence is the one that no score captures. Every visitor's IP address went to a company they had not chosen to talk to, on every page, in exchange for a number in a footer.

Removing it is not the answer either

We had already written down the trade-off, in a comment, months earlier: if you ever want this without a consent banner, self hosting the SVG is the route. With the counterargument attached, which is that a self hosted copy is a photograph. The badge exists to show a live listing; freeze the bytes and you have a picture of a listing.

There is a third way, and it is only obvious once you separate two things a browser is doing at once. The browser is fetching bytes, and the browser is making a connection. Only the second one is the problem.

So the badge now comes from our own origin, at a path on our own API, and that route fetches the upstream on the server. Same bytes. The visitor's browser makes one request, to us. There is nothing on the wire to set a cookie on, because there is no third party in the visitor's page at all. And the number stays live, because we are still asking.

Best practices went from 78 to 100 on thirty two of thirty six measurements. The remaining four are the article pages, which sit at 96 because of an entirely different third party button, and that is a separate decision rather than a leftover.

What a proxy owes the person it proxies for

Serving somebody else's bytes from your own origin changes what they are. An SVG loaded through an image tag cannot execute script; that is a specification guarantee and it is a good one. But the moment those bytes come from our domain, what is in them is ours.

So the route checks the answer before it passes it on. That check is deliberately not a sanitiser. A sanitiser tries to make dangerous content safe; this one refuses anything that does not look like the badge we already know, and the route falls back to a frozen copy. The worst case is a badge from this morning rather than a badge from this minute, which is a price worth paying for never having to reason about what somebody else's response could contain.

Two of the refusals are specifically about the thing this whole change was for. An SVG can reference an external image, and it can reference an external resource from a style declaration. Either of those would reach out to a third party one layer deeper than the tag we just removed, from inside a file we are serving as our own. A check that only looked for scripts would have passed both.

Failing to a picture, on purpose

Every failure path returns the frozen copy with status 200. Network error, timeout, a status that is not 200, a body the check does not trust: all of them, 200, with a badge in it.

The reasoning is about what the failure looks like. A 404 or a 502 on an image in the footer of every page is a broken image icon on every page, which reads as a broken site, for a decorative element. A slightly old badge reads as nothing at all.

The compensation is that the failure path carries a short cache header while the success path carries a long one. Otherwise a five minute outage upstream would pin the frozen copy in front of everybody for the full cache window.

There is one frozen copy and it deliberately does not live as a file in the public directory. As a file it would have a second public address next to the route, and a future round could reasonably drop an image tag pointing straight at it, which quietly removes the live behaviour that this entire arrangement exists to preserve. Kept as a constant, the only way to serve it is through the route that also tries the network first.

Two caches, doing different jobs

There are two caching layers here and confusing them produces a system that either hammers the upstream or shows stale bytes forever.

The framework's data cache holds the upstream answer: with a six hour revalidate, one visitor every six hours causes one outbound request, regardless of traffic. The HTTP cache header holds our answer, at the CDN and in the browser. The upstream sets four hours on its own response; six on our side is a deliberate half step wider, so that an outage over there does not drop us onto the frozen copy on a four hour rhythm.

One small trap, which cost a build. Next reads route segment configuration statically out of the source, so the revalidate export has to be a literal number. Writing it as an expression, even as a constant declared a few lines further down in the same file, aborts the build with an invalid segment configuration error. The constant now reads from the export instead of the other way round, so there is still exactly one number.

The 307 that was a start flag

The same round spent time on something that looked like a routing bug and was not, and it is worth recording because it wasted a previous round entirely.

Running the production build locally, every bare marketing path answered 307, redirecting to itself. Not the localised paths, not the API routes: the bare ones. This had blocked the English half of every local layout sweep for weeks and had been written down as an unexplained defect.

It is the host binding. Started with a literal loopback address as the hostname, Next leaves that value as it is, while an empty or wildcard hostname is normalised to localhost. The initial URL and the origin the middleware computes then disagree, the rewrite stays absolute, the router treats it as external, and re-enters. Started with the wildcard, which is the value production uses, or with localhost, everything answers 200.

There was nothing to repair. What there was to do was write the correct start line into the script, and write down why, because a defect that is really a flag will otherwise be rediscovered by the next person with the same confidence.

The failure we chose not to fix

A third thing from the same round is a deliberate non repair, and those deserve to be written down at least as carefully as fixes.

We had seen chunk 404s in the field: HTML referencing JavaScript files that no longer exist. The explanation turned out to be one header. The homepage carries a stale-while-revalidate window measured in a year, so a CDN edge is permitted to serve HTML from an older build for a very long time, and the chunk names in that HTML belong to that build.

The knob is right there and we left it alone, because that same stale copy is what kept the site answering during a database crash loop a fortnight earlier. Shortening the window trades a rare broken load against a rarer total outage. That is a real trade, it belongs to whoever owns the risk, and the round's job was to explain the mechanism rather than to make the call.

What we would write down

  1. A uniform score across every page points at something every page shares. That is the cheapest lead you will get.
  2. An image tag is a connection, not just bytes. Everything a CDN wants to do on a request, it does on that one too.
  3. Self hosting is not the only alternative to embedding. Fetching on the server keeps the live behaviour and removes the third party from the visitor's page.
  4. Once you serve somebody else's bytes, they are yours. Check them, and check specifically for the references that would reopen the hole you just closed.
  5. Prefer a stale picture to a broken image. For decoration, a visible failure costs more than an invisible staleness, as long as the stale window is short on the failure path.
  6. Do not give the frozen copy a public address. Anything reachable will eventually be linked, and the live path will be quietly bypassed.
  7. Know which cache holds what. One holds the upstream answer, one holds yours, and their windows answer different questions.
  8. Some red is a flag, not a bug. Reproduce the mechanism before you repair the symptom, and if it turns out to be your own start command, say so in the script.

For clarity about what this is: the route is live and the scores are from a local production build across thirty six measurements, not from field data. The chunk 404 explanation is derived from headers and has not been reproduced, and the decision about that cache window belongs to the person who carries the availability risk, not to the round.

From the platform work on Provibr. Another measurement that was green for the wrong reason is in an accessibility exception that measures itself, and a field that held an intention where a fact belonged is in a date in the future is not a schedule.

Start with one host and one agent

Create an account, install the agent with a single command and connect your first Proxmox host. The trial runs for 14 days and asks for no payment details.