Short version: a proxy pool of headless browsers was loading a site I run, executing its JavaScript, and therefore firing its analytics. It was not scraping, it was browsing, as far as every tool I had could tell. On the day I finally measured it properly, 53 of 76 script-fired pageviews came from one network, so roughly 70% of what I thought was an audience was infrastructure. Three things made the difference between catching it and staring at it for another year: picking a marker the CDN could not absorb, blocking at the layer that can still see who is calling, and measuring the collateral damage before pulling the trigger rather than after.
I run a bilingual news site alongside the consulting work. For months its analytics told a pleasant story: steady growth, a healthy slice of readers from Singapore, nothing alarming. The story was fiction, and the reason it survived so long is worth more than the incident itself.
Why a scraper is easy and this was not
The bots most people picture announce themselves. They fetch HTML, ignore the script tags, and carry a User-Agent that says what they are. Analytics never sees them, because analytics is JavaScript and they do not run JavaScript.
This traffic ran it. Headless browsers execute the page, so the GA4 tag fires, a session opens, and a pageview lands in the report next to the ones from actual people. Every dashboard I had was downstream of that tag. Asking any of them whether the traffic was real meant asking the bot to grade its own homework.
The signature, once I could see per-request detail, left nothing to argue with:
- 169 unique addresses for 181 hits. That is not a crawler working through a site, it is a pool handing each request to whichever address was free.
- 159 of those addresses appeared exactly once. Rate limits and per-IP thresholds are blind to this by construction. Nothing ever hits a threshold.
- One page load spread across four different addresses inside two seconds. The HTML, then the fetches the page makes, arriving from four places as though four people had coincidentally loaded the same article in the same breath.
- No declarative bot User-Agent anywhere, and Chrome version strings distributed evenly across releases 75 to 152, which is not how a real audience updates its browsers.
The marker that would have lied to me
Measuring this needs an endpoint that only a real browser session touches. My first instinct was the obvious one: count requests for the main application bundle. A browser loads it, a plain crawler does not, so the ratio should expose the difference.
That measurement would have come back clean and wrong.
The bundle ships with a long cache lifetime, which means the CDN edge answers almost every request for it and the origin never hears about them. I would have queried my own logs, seen a number close to zero, and concluded there was no script-executing traffic at all. The tool would have reported the opposite of the truth, confidently, and I would have had no reason to doubt it.
What worked was choosing endpoints the edge is not allowed to keep: a handful of authenticated and engagement routes that carry Cache-Control: no-store, have no href anywhere in the markup pointing at them, and exist only as fetch() calls from the application script. A client that reaches those has run the JavaScript. There is no other route in.
That is the transferable part of this. Before trusting a measurement, ask what would have to be true for it to be wrong, and check that first. A marker sitting behind a cache is not measuring your origin, it is measuring your CDN's hit rate, and the two numbers look identical until one of them changes your mind about something.
Blocking where the caller is still visible
The site sits behind a CDN, which has a consequence people discover at exactly the wrong moment. At the socket layer, every connection arrives from the CDN's edge. The operating system firewall sees those addresses and nothing else. A rule written there against the offending network would have matched nothing, or worse, matched the CDN and taken the site off the internet.
The real client address only exists inside a request header the CDN adds, and only becomes usable once the web server is told to trust that header and rewrite the connection's address from it. Nginx's realip module does that rewrite, and it has to be restricted to the CDN's own ranges, because a header anyone can set is a header anyone can forge.
So the block belongs in the web server, after that rewrite, not in the firewall underneath it. I used the geo module to map the network's announced prefixes to a flag and returned 403 on it. Roughly 1,100 IPv4 prefixes, a little over 2.6 million addresses, plus the IPv6 ranges.
There is an ordering detail that cost me a rebuild to notice. The geo block has to be defined before the server block that reads it, which for a typical layout means it goes in the configuration directory that gets included first, not alongside the site.
Two checks before pulling the trigger
Blocking two and a half million addresses on the evidence of one bad day is how you take out real readers and never find out.
I measured the collateral first. Every request from those ranges in the window, 182 of them, carried the bot shape: the same handful of engagement endpoints, no form posts, no login attempts, no admin paths. Meanwhile the site's actual regional readers arrive from two completely different networks. The overlap was zero, and I knew that before the rule went live rather than after a complaint.
I excluded my own server. The machine's public address happened to sit inside the same network as the traffic I was blocking. An ASN-wide rule would have included it, so the origin could have been refused by its own site the first time anything made it call itself by public address. Measured hits from that address: zero. It is insurance rather than a repair, and it costs one line.
The part that expires
Prefix lists go stale. Networks announce and withdraw routes continuously, so a list captured today drifts from reality, and it drifts in the silent direction: the rule keeps matching fewer and fewer addresses while continuing to look exactly as installed. Nothing fails and nothing alerts, so coverage erodes with no symptom attached to it.
So the list carries a comment naming the registry API it came from, the date it was pulled, and the one-line command that regenerates it. A control with no expiry procedure is a control that quietly becomes decoration, and the failure mode is invisible by design.
What this cost and what it bought
The measurement, the blocklist and the write-up took an afternoon. What it bought was not the blocked traffic, which was costing almost nothing in bandwidth. It bought the ability to trust a number again.
Before this, every decision about the site rested on a dataset that the 53-of-76 count above puts at roughly 70% contamination on its heaviest day. Content decisions, growth reads, where to spend effort next. All of it computed from a mixture of readers and a proxy pool, with no way to separate them, and all of it therefore wrong in ways that pointed in no consistent direction.
That is the case for treating analytics contamination as a security problem rather than a reporting nuisance. Nobody stole anything and nothing went down. The damage was that a year of decisions were made against a number that was lying, and the tools whose job is to notice were the ones telling the lie.
If your own numbers have a region or a channel that has never quite made sense, the measurement above is cheap to repeat and it answers the question in a day. That kind of question, and the build and cluster equivalents of it, is what I do in a fixed-scope review.
Comments
Be the first to comment.