# How to verify GPTBot and ClaudeBot by IP, not user agent

Test the request's IP against the ranges the operator publishes: openai.com/gptbot.json for GPTBot, claude.com/crawling/bots.json for ClaudeBot. A match is proof. A miss is weaker evidence than it looks, because the lists change. On this site, 4 of 81 requests labelled forged were real ChatGPT-User fetches from ranges added after the 78-day-old snapshot, and 3 came from Anthropic's own unlisted network.

Published: 2026-10-28
Canonical: https://umar.codes/verify-ai-crawlers-by-ip

## Revisions

- 2026-10-28 — Created from the production crawler log, exported that day (7,550 records, 2026-07-27 to 2026-10-28), re-checked against the operators' range files fetched that day.
- 2026-10-28 — Published on its Oct 28 slot; auto-drafted by the publishing run because no draft existed for the row.

---

Test the request's IP against the ranges the operator publishes, not the user
agent. OpenAI publishes one JSON file per crawler, and Anthropic publishes one for
all three of its agents. An address inside a range proves the claim. An address
outside one is weaker evidence than it looks, because the lists change and do not
cover everything.

A user agent is a string the client writes, and anything can write it. Before [this
site checked addresses](/logging-ai-crawlers), 436 of its first 977 logged "crawler" requests, 44.6%,
asked for paths that only a credential scanner asks for: `/.env`, `/.ssh/id_rsa`,
`/@fs/proc/self/environ`. The log's busiest "AI agent" was a scanner.

## Where GPTBot, ClaudeBot and the other crawlers publish their IP ranges

Ten of the 30 agents this site logs come from operators that publish their egress
ranges as JSON. Each file has a `prefixes` array of `ipv4Prefix` or `ipv6Prefix`
entries in CIDR form.

| Agent | Range file |
|---|---|
| GPTBot | `https://openai.com/gptbot.json` |
| OAI-SearchBot | `https://openai.com/searchbot.json` |
| ChatGPT-User | `https://openai.com/chatgpt-user.json` |
| ClaudeBot, Claude-User, Claude-SearchBot | `https://claude.com/crawling/bots.json` (one file for all three) |
| PerplexityBot | `https://www.perplexity.com/perplexitybot.json` |
| Perplexity-User | `https://www.perplexity.com/perplexity-user.json` |
| Bingbot | `https://www.bing.com/toolbox/bingbot.json` |

Anthropic's file is easy to miss. On 10 August I recorded that Anthropic published
no ranges, after four guessed hostnames returned 404. The file was linked from
Anthropic's crawler support article the whole time, and I corrected the record the
same day. Read the operator's crawler documentation before you decide a list does
not exist.

Two cases have no list of their own. `Google-Extended` is a robots.txt token, not a
crawler, so a request whose user agent says `Google-Extended` is forged by
definition; this site's logger checks it against Google's crawler ranges anyway,
and all 8 such requests failed. This site has no range list for Meta, Apple, Amazon,
ByteDance or the research crawlers, so their requests stay `unknown`. I have not
checked every one of those operators for a list, and after the Anthropic mistake I
do not assume there is none.

## The lookup: a CIDR test with no network call

The check runs in this site's middleware on every logged request, so it must cost
nothing. The ranges are fetched by hand into a committed snapshot, and the request
path does integer arithmetic only:

```ts
type Verdict = "verified" | "forged" | "unknown";

function verifyClaim(ip: string | null, bot: string, ranges: Record<string, string[]>): Verdict {
  const cidrs = ranges[bot];
  if (!cidrs?.length || !ip) return "unknown"; // no list, or no address: no evidence
  return cidrs.some((cidr) => ipInCidr(ip, cidr)) ? "verified" : "forged";
}
```

`ipInCidr` parses both sides to a `bigint` and compares the top `bits` bits. Two
details matter. An IPv4-mapped IPv6 address such as `::ffff:203.0.113.9`
must be unwrapped to its IPv4 form, or a proxied client never matches the IPv4
prefixes operators publish. And an IPv4 address must never be compared with an IPv6
prefix: as raw integers, `0.0.0.1` equals `::1`.

There are three verdicts, not two. `unknown` means no evidence either way, and it is
never counted as a pass or a failure.

## What 78 days of verified requests show, per agent

IP logging went live on 10 August, and [what the verified crawlers read](/what-ai-crawlers-read) is its own post. From then to 28 October, 78 days, the log holds
6,573 requests with an address. For the agents with published ranges, this is what
the logger recorded:

| Agent | Requests | Verified | Forged |
|---|---|---|---|
| ClaudeBot | 1,894 | 1,889 | 5 |
| Bingbot | 479 | 479 | 0 |
| OAI-SearchBot | 314 | 305 | 9 |
| GPTBot | 301 | 296 | 5 |
| PerplexityBot | 172 | 165 | 7 |
| ChatGPT-User | 56 | 32 | 24 |
| Claude-User | 31 | 9 | 22 |
| Google-Extended | 8 | 0 | 8 |
| Claude-SearchBot | 1 | 0 | 1 |
| Perplexity-User | 0 | 0 | 0 |

The last row is the clearest result in the log. Before addresses were checked,
`Perplexity-User` was the log's largest agent: 347 requests, 321 of them for scanner
paths. Its last request came on 9 August. In 78 days since, not one request has
claimed to be `Perplexity-User`, real or forged. I report these per agent and not
as one total, because a total measures which operators publish lists, not how much
traffic is real.

The 81 forged requests came from 12 addresses. Two Google Cloud addresses sent 53 of
them, in two bursts on 12 and 13 August, each wearing six crawler names in turn:
ClaudeBot, GPTBot, OAI-SearchBot, ChatGPT-User, PerplexityBot and Google-Extended.
They asked for `/.aws/credentials`, `/etc/passwd` and `/proc/self/environ`. A
scanner that rotates user agents does not care which one you trust; it is looking
for the one you exempt from rate limits.

## Why 7 of the 81 "forged" requests were not forgeries

I re-checked all 81 against the range files as they stood on 28 October, then
looked up the owner of every address. Two groups do not fit the label, and a third fits it only narrowly.

**A stale snapshot: 4 requests.** This site's snapshot was taken on 10 August and
was 78 days old. Since then OpenAI's ChatGPT-User file gained 33 prefixes and lost
54, and Anthropic's file replaced 16 of its 20 prefixes with 10 new ones, leaving 14.
Four ChatGPT-User requests on 19 and 27 October came from addresses in ranges added
after the snapshot. They were real, and the logger called them forged.

**The operator's own unlisted network: 3 requests.** Three Claude-User requests on
21 October, with Anthropic's full user agent string, came from `160.79.106.0/24`.
Whois assigns `160.79.104.0/21` to Anthropic, PBC, and `bots.json` does not list it.
Anthropic's support article says an address on the list means the crawler is
Anthropic's. It does not say the reverse, and these three show why that matters.
I cannot prove they were Claude-User, but "forged" claims more than I know.

**A user agent no range list can cover: 1 request.** One request today said
`Claude-User (claude-code/2.1.312)` and came from a hosting provider's address.
Claude Code runs on its user's own machine, so a fetch it makes leaves from that
machine. I count it as forged, because it is not Anthropic's infrastructure, but it
is not an attack either.

The other 74 hold up. Most are the two scanner bursts. The rest include 14 requests
in one second from an AWS address with the bare user agent `Claude-User`, asking
only for MCP discovery paths such as `/.well-known/mcp.json`, and four `/.env`
probes wearing Claude-User's full string from a small hosting provider.

## Three rules for verifying crawlers by IP

- **A match is proof; a miss is a question.** Treat `verified` as fact. Treat
  `forged` as "outside the list on the day of the snapshot", and look up the owner
  of the address before you call it hostile. For agents that fetch on a user's
  behalf, an outside address is common.
- **Refresh the snapshot on a schedule.** Four of the 81 errors came from a
  78-day-old copy, and OpenAI's ChatGPT-User file was regenerated the day before I
  checked. This site still refreshes by hand, which is how it got to 78 days. A rise
  in `forged` for an agent that was clean usually means a stale list, not a new
  attacker.
- **Label, do not drop.** Every row keeps its address and verdict. That is the only
  reason I could re-check 81 verdicts against today's lists and find the 7 that were
  wrong. If I had blocked or deleted them on the day, the error would have stayed
  invisible.
