This site has no audience. It published nothing until this week, has no inbound links, and its total human readership is me checking that a deploy worked.
It still received 293 requests from software identifying itself as an AI crawler in five days.
I wanted to know which of those were real. The answer is that most were not — and the ones doing the impersonating were not interested in the content at all. They were looking for my environment file.
Why a user-agent string proves nothing
I did not know until last week that there is a documented way to check this, and that it is an old one.
A crawler announces itself in the User-Agent header. That header is a string
the client chooses. There is no signature on it, no key exchange, nothing to
forge. Writing GPTBot/1.2 into a request costs nothing and requires no
relationship with OpenAI.
The check that does work is forward-confirmed reverse DNS, and it works like a callback:
dig +short -x 66.249.66.1 # → crawl-66-249-66-1.googlebot.com
dig +short crawl-66-249-66-1.googlebot.com
# → 66.249.66.1 the address we started from
Resolve the address to a hostname. Resolve that hostname back to an address. Accept only if you arrive where you started and the hostname sits in the operator’s domain. A scraper can put anything in a header; it cannot forge the PTR record for an address it does not control.
The second half is the part that matters and the part that gets skipped. A PTR record on its own proves nothing, because whoever controls the address can set it to whatever they like. Only the round trip is evidence.
If that shape feels familiar, it is the same reflex as checking a webhook signature instead of trusting the payload, or pinning a certificate instead of trusting the name on it. The SEO literature treats crawler identification as string matching. It is authentication, and authentication has known answers.
Some operators do it differently. Google and OpenAI publish prefix lists — a JSON file of the address ranges their crawlers use — and expect you to check membership. Where a list exists it is better evidence than DNS, because it is the operator stating the answer directly rather than you inferring it.
What I did
Five days of Apache access logs, 10 to 15 September, from a server I have root on. That last part matters more than it sounds: almost everyone writing about AI crawler behaviour is on managed hosting, reading a third-party dashboard and inferring. The raw log is the only place this information exists.
One setup detail nearly invalidated the whole thing. The site sits behind
Cloudflare, so every request arrives at my origin from a Cloudflare edge
address, not from the client. Apache logged the proxy. Enabling mod_remoteip
with Cloudflare’s published ranges as trusted proxies restores the real client
address — and the trusted-ranges part is not optional, because without it
anyone reaching the origin directly could forge the header and write fake
crawler hits into my own dataset.
Rows logged before that fix carry edge addresses and cannot be verified either way. They are excluded here rather than counted as failures, since they are an artefact of my own setup and would otherwise have dominated the numbers.
Every remaining source address was then checked against the operator’s prefix list where one exists, and by forward-confirmed reverse DNS otherwise.
What came back
Fourteen distinct identities appeared. Four of them survived verification:
| Claimed | Requests | Verified | Disconfirmed |
|---|---|---|---|
| Googlebot | 34 | 22 | 12 |
| PetalBot | 19 | 19 | 0 |
| OAI-SearchBot | 21 | 13 | 8 |
| Bingbot | 5 | 5 | 0 |
| ClaudeBot | 59 | 0 | 42 |
| PerplexityBot | 33 | 0 | 33 |
| meta-externalagent | 19 | 0 | 19 |
| Bytespider | 17 | 0 | 17 |
| ChatGPT-User | 17 | 0 | 17 |
| Applebot | 15 | 0 | 15 |
| CCBot | 15 | 0 | 15 |
| Amazonbot | 13 | 0 | 13 |
| Google-Extended | 13 | 0 | 13 |
| GPTBot | 13 | 0 | 13 |
Every request claiming to be GPTBot failed. Every request claiming to be PerplexityBot failed. Google-Extended, the agent Google uses for AI training data and the one publishers argue about blocking, appeared thirteen times and verified zero.
Meanwhile PetalBot — Huawei’s crawler, which nobody writes thinkpieces about — verified nineteen times out of nineteen.
The three addresses doing most of it
Grouping by source address rather than by user-agent changed the picture completely.
Three addresses accounted for 533 requests between them, 217 of which wore a crawler identity. Each of the three claimed twelve different identities at different moments: Googlebot, GPTBot, ClaudeBot, PerplexityBot, Applebot, Amazonbot, CCBot, Bytespider, ChatGPT-User, OAI-SearchBot, Google-Extended and meta-externalagent. The same address, rotating costumes.
All three reverse-resolve into bc.googleusercontent.com. They are rented
Google Cloud instances.
What settles it is not the DNS, though. It is what they asked for:
/.env
/.env?raw
/@fs/../.env?import&raw??
/graphql
/api/graphql
/v1/graphql
/read-document
That is not a crawler. No search engine or language model needs my environment file. This is a vulnerability scanner that puts on crawler user-agents, and my guess — and it is a guess — is that it does so because a request claiming to be Googlebot is less likely to be blocked than one claiming nothing.
The impersonation is not an attempt to be indexed. It is camouflage.
The ClaudeBot problem, which is not the same problem
Fifty-nine requests claimed to be ClaudeBot. Forty-two came from those three Google Cloud addresses and are disconfirmed along with everything else they sent.
The remaining seventeen came from addresses I cannot verify at all, in either direction.
Anthropic publishes no prefix list that I could find at any of the URLs I
tried. The addresses in question — in 216.73.216.0/22 — return no PTR record,
so there is nothing to forward-confirm. Neither documented method applies.
This is worth separating carefully, because the tempting move is to lump it in with the failures and report a bigger number. Those seventeen requests are unknown, not fake. They may well be genuine. I have no way to find out, and saying otherwise would be exactly the sloppiness this whole exercise is about.
I have recorded them under a distinct state in the database — none, meaning
no verification method was available — rather than folding them into the
disconfirmed count. If Anthropic publishes a range list later, those rows can
be re-evaluated without re-collecting anything.
Nobody is reading llms.txt
A smaller finding, and the one I expected least.
This site serves /llms.txt, the proposed convention for giving language
models a plain-text map of a site. It received 27 requests in five days.
Exactly one came from a verified crawler, and it was PetalBot.
The other 26 came from addresses carrying no recognised crawler identity at
all. Some are likely the same scanner traffic; llms.txt is a well-known path
now, and well-known paths get probed.
What did the verified crawlers actually fetch? Almost entirely robots.txt.
Googlebot asked for robots.txt nine times, the homepage twice, a stylesheet
three times and the favicon twice. OAI-SearchBot asked for robots.txt thirteen
times and nothing else at all.
Thirteen requests for the rules and zero for the content. The verified agents arriving here are checking whether they are allowed in, then leaving.
The traffic underneath all of this
The crawler question is a small slice of what actually arrives. In the same
window the site absorbed thousands of requests for /xmlrpc.php,
/wp-login.php, /wp-admin/ and /?author=1.
There is no WordPress here and no PHP handler at all — the site is static files — so every one of those returns a 404 or a 403 and costs nothing. But it is a useful reminder of proportion. The AI crawler conversation is loud, and on a small site the AI crawler traffic is a rounding error against automated probing.
It also produced an unexpected argument for the static migration I did last
week. Those /?author=1 requests are WordPress user enumeration: on a
WordPress install they return the author’s username. They are still arriving,
270 times in the window, and now they return a static homepage that contains
nothing.
How to run this yourself
The method is more useful than my numbers, so here is the whole of it.
You need the log from the machine that answers the request. If a CDN sits in front of your site, its cached hits never reach your origin and never appear in your log. If you are on a platform that does not give you raw access logs at all, this experiment is not available to you, and that constraint is most of why so little of this gets published.
Restore the real client address before you collect anything. Behind a proxy
your log records the proxy. On Apache that is mod_remoteip with the CDN’s
published ranges as trusted proxies; nginx calls it realip. Set the trusted
ranges explicitly. Without them the header is forgeable by anyone who reaches
your origin directly, and you will have built a dataset that an attacker can
write to.
Verify in this order. Check the operator’s published prefix list first where one exists — it is the operator answering directly. Fall back to forward-confirmed reverse DNS. Record which method produced each verdict, because “failed DNS” and “no method exists” are different claims and collapsing them is how you end up publishing a number you cannot defend.
Cache the verdicts by address, not by request. Crawler addresses repeat heavily and every lookup is a network round trip. A few hundred lookups covered 70,000 rows here.
Group by address before you draw any conclusion. This is the step that changed my result completely, and it is one line of SQL.
The parser, schema and verification logic behind this post are a few hundred lines of Python with no dependencies beyond the standard library. It runs nightly. I will publish it as a tool once it has survived a few more weeks of its own output.
What this means if you are doing this work now
Any crawler report built on user-agent strings is wrong. Not imprecise — wrong, in a specific direction. It will overstate AI crawler interest, because impersonation inflates exactly those numbers and nothing corrects for it. If a tool shows you GPTBot traffic and cannot tell you how it verified it, it is showing you a header.
Blocking by user-agent fails in both directions. A robots.txt rule
against GPTBot is honoured by the real GPTBot and ignored by everything wearing
its name. You block the agent that was going to behave and inconvenience nobody
else.
Group by address before you conclude anything. Fourteen identities in my logs collapsed to a much smaller number of actors once I grouped by source. The user-agent column invented a crowd.
Publish your uncertain rows as uncertain. The ClaudeBot seventeen are the most interesting rows in the dataset precisely because I cannot resolve them.
What remains unknown
One site, five days, no audience. The absolute counts here mean nothing — the ratios are the finding, and even those come from a sample small enough that a single scanner shifts them.
I cannot tell whether the same ratio holds on a site with real traffic. It may be that impersonation is a fixed background noise, in which case a busy site would see the same absolute volume and a far better ratio. That is a different experiment and it needs a site I do not have.
I cannot tell what the three scanners were ultimately for. Reading .env
requests tells you what they wanted, not who wanted it.
I do not know whether the seventeen unverifiable ClaudeBot requests are genuine. I have asked the question the only way I can, which is publicly and with the data attached.
And I cannot yet say anything about whether appearing in these logs correlates with appearing in an AI answer. That is the question worth answering, it needs months rather than days, and the log shipping that produced this post is now running nightly so that the version of it I write in January has something behind it.