Your analytics tool is counting bots as people
Bot filtering in analytics products is a user-agent blocklist, so it only catches bots that admit what they are. Here are three ways to identify the ones that don't, why your analytics vendor structurally can't do the best of them, and the Cloudflare rule that stops them at the edge.
Every analytics product filters bots. Almost none of them filter the ones that matter.
The filter is a user-agent blocklist. posthog-js drops anything whose UA
contains bot, spider, crawl or headlesschrome, plus a
navigator.webdriver check. GA4 works off the IAB/ABC spiders-and-bots list.
Both are the same idea: catch the robots that declare themselves.
Plenty do. The interesting ones don't. So a headless browser with a spoofed user agent and a real JavaScript engine executes your page, fires your analytics snippet, and gets counted as a person by every tool in that list - because it never said otherwise.
On a small site this isn't a rounding error. It's the difference between the number you report and a number several times smaller.
Here's how to tell, using signals your analytics tool either doesn't look at or structurally can't see.
1. Real people don't arrive on a schedule
The cheapest tell, and it needs nothing beyond the session list you already have.
Look at the minute past the hour each session started. Humans scatter. A scheduled job doesn't.
We pulled a fortnight of sessions for one marketing site on 19 August 2026. One signature - reported as Microsoft Edge on Windows, from the US, one visit per day
- landed at these minutes past the hour:
33, 33, 34, 34, 34, 35, 35, 35, 35, 35
Ten independent humans do not arrive inside a three-minute band on ten different days. That's a cron entry. A second signature, reported as Chrome on iOS, landed at 51, 51, 52.
So sort your sessions by minute-past-the-hour and the clusters fall straight out. No tooling required, and it takes about five minutes.
2. The user agent contradicts itself
Once you have a suspect, read its full user agent rather than the tidy "Microsoft Edge / Windows" your dashboard renders. The one above was:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/125.0.6422.60 Safari/537.36 Edge/12.246
Chrome 125 shipped in 2024. Edge/12.246 is Edge Legacy, from 2015. No real
browser has ever sent both. Whoever assembled that string pasted two eras
together.
The second-order problem is worse than the fake itself. UA parsers take the trailing token as the browser, so this gets filed as Microsoft Edge on Windows - a plausible, boring, entirely fictional user sitting in your dashboard looking like your audience. The spoof doesn't just evade the filter, it actively populates your demographics.
Check version pairs against release dates. Contradictions turn up a lot in spoofed strings, probably because whoever wrote them was copying rather than simulating.
3. Where it actually came from - and why your vendor can't see it
The strongest signal is the source network. Humans don't browse from a datacentre. Bots overwhelmingly do.
Correlating the same window against server-side request traces: of the addresses
POSTing to the analytics capture endpoint in 24 hours, the substantive ones were
34.122.147.229 (Google Cloud, us-central1) and 54.200.149.144 (AWS,
us-west-2). Both fetched main.css, main.js and the full analytics bundle -
a complete browser doing a complete page load, from a rack.
Your analytics vendor probably can't do this check, and if you've done privacy properly it's your own doing. Proxying analytics through your own domain and stripping the visitor IP before forwarding is the correct call - it keeps the address out of a third party's hands. It also means the vendor sees your server's address, not the visitor's, so source-network filtering becomes something only you can do.
Which is a real trade, and worth making deliberately rather than by accident. You keep the privacy property (good) and you inherit the filtering job (less good).
You need server-side logs or traces to do it at all, which is the argument for having them regardless: the client-side tool cannot see the thing that identifies its own worst data.
Stopping it at the edge
The CDN already knows the source ASN, already maintains the ASN-to-prefix mapping, and already sits in front of every request. That's the right altitude - better than shipping a cloud-prefix list inside your application and maintaining it yourself.
One Cloudflare WAF custom rule in the http_request_firewall_custom phase,
action block:
(starts_with(http.request.uri.path, "/.ph/e")
or starts_with(http.request.uri.path, "/.ph/batch")
or starts_with(http.request.uri.path, "/.ph/capture")
or starts_with(http.request.uri.path, "/.ph/s"))
and ip.src.asnum in {16509 14618 15169 396982 8075 14061 24940 213230 16276
63949 132203 45090 45102 37963 51167 12876 20473}
Substitute your own proxy's capture paths. Match only the ingestion endpoints, not the whole analytics path - feature-flag, config and asset fetches should still pass, so an affected client's page behaves normally and only its event capture dies.
Two things verified against the live API on 20 August 2026, both of which shape the rule:
- ASN matching works on Cloudflare's Free plan.
ip.src.asnumis accepted in WAF custom rules. This surprised us; it's the sort of thing usually gated. - The
matchesregex operator is not. It returnsnot entitled: the use of operator Matches is not allowed, a Business plan or a WAF Advanced plan is required. Hence thestarts_withOR-chain above rather than one tidy pattern.
The ASN list covers the major clouds - AWS, Google, Azure, DigitalOcean, Hetzner, OVH, Linode, Tencent, Alibaba, Contabo, Scaleway, Vultr. Confirm each entry before you deploy it; an ASN list copied from a blog post is a list of someone else's assumptions.
Blocking Google's 15169 sounds alarming and isn't. The rule only touches
analytics ingestion, so crawling and indexing are untouched.
Who actually gets to make this call
In most organisations, not the engineer who found it. Marketing owns the number. Engineering owns the edge and can implement any filter you like, but the reported figure belongs to whoever is judged on it, and that is not the person writing the ASN rule.
Which makes this a harder conversation than it looks, because you are arriving with a change whose entire effect is to make somebody's number smaller. Not smaller and more accurate in some abstract sense - smaller on the slide that goes to the board next week, against a figure already reported last quarter using the old method.
So bring the evidence rather than the principle. The minute-of-hour clustering, the impossible user-agent pairings, the n pageviews = n persons = n sessions on one path. Those are checkable in the tool the marketing team already uses, by them, without taking your word for anything. An argument about bot filtering is lost before it starts; a list of sessions that are obviously one machine is not an argument at all.
Who else this blocks
This also drops event capture from real humans behind cloud-hosted VPN egress or a corporate proxy on one of those networks.
For marketing sites that's obviously the right trade - the population is small and the bot contamination is large. For authenticated application hostnames it is not, and defaulting it there would mean a customer on a corporate cloud proxy silently vanishing from your product analytics, which you'd then be diagnosing from an absence. Roll it out to marketing zones first and decide app hostnames separately, on evidence.
What this doesn't fix
- Residential-proxy scrapers are outside what any ASN list catches. They egress through consumer connections precisely so this check fails. The expected outcome is materially lower, not clean.
- It's retroactive to nothing. Historical figures stay contaminated, so any before-and-after comparison spans a methodology change. Note the date you applied it and stop comparing across it.
- The list needs maintenance. New cloud ASNs appear. This is a rule that decays quietly, which is the worst way for a rule to decay - schedule a review rather than trusting it indefinitely.
Underneath all three techniques is the same thing: the tool that reports your traffic is the wrong place to judge whether that traffic is real. It only ever sees what the client chose to tell it, so judging it needs a signal from somewhere the client doesn't control - your own logs, or the edge sitting in front of them.
The minute-past-the-hour check costs nothing and needs no tooling, so it's the one to run first. If you do, I'd be interested to hear what falls out - my guess is that most people have never thought to look.