Early September, the explicit-lookup endpoint on my IP-reputation site started seeing tens of thousands of distinct source IPs a day, and most of them queried exactly once. Every request asked about its own address. The sources were residential and mobile ISPs across Europe: BT, Vodafone Italy, Orange, Charter, Telenor.
That shape is somebody validating a residential proxy pool against my endpoint, one exit at a time. And it walks straight past per-IP daily quotas, because a quota caps depth and this has no depth. Thirty thousand exits at one query each sits under any per-IP limit you'd want to set, while spending a full upstream fan-out per request. AbuseIPDB's daily allowance was gone in three hours.
Matching the signature buys you about a day and a half
First attempt: block the signature. wrangler tail showed the fleet's requests were internally impossible under Fetch Metadata. They announced sec-fetch-mode: navigate with dest: document and site: none, which is what an address-bar navigation looks like, while carrying accept: application/json, text/plain, */*, which is axios's default. They carried an Origin, which a GET navigation never has. And a Referer, while claiming site: none, meaning no initiator at all.
I shipped a rule for exactly that. Thirty-six hours later the fleet came back as mode: cors, site: same-origin, still axios's Accept, with a Referer copied from a real page request. A clean impersonation of my own page's fetch, and it went through to full upstream. A day after that, version three: a complete top-level navigation, text/html, no Origin, no Referer.
Signature matching was always going to lose this race. Every rule I ship works as a free oracle for them: flip one header, retry, and the response says which header it was.
What failed worse was my own shortlist of obvious tells. I had three candidates out of the tail logs: connection: Keep-Alive on HTTP/2, which is illegal; x-real-ip and x-forwarded-proto; and a missing priority header. All three turned out to be present or absent on real human traffic in the same minute. Cloudflare and my adapter layer put them there. Ship those and I'd have been 403ing real people without ever finding out. A detection rule has to be falsified against known-good samples before it goes anywhere near a block.
Contradictions instead of signatures
The checks that survived all name a pair of headers the browser generates from one source, which therefore cannot disagree.
const mode = (h.get("sec-fetch-mode") ?? "").toLowerCase();
const site = (h.get("sec-fetch-site") ?? "").toLowerCase();
const accept = (h.get("accept") ?? "").toLowerCase();
const ua = h.get("user-agent") ?? "";
// 1. Claims a top-level navigation, carries things only XHR/axios sends.
const forgedNavigation =
mode === "navigate" &&
(accept.includes("application/json") ||
h.get("origin") !== null ||
(site === "none" && (h.get("referer") ?? "") !== ""));
// 2. Claims a page fetch, carries navigation-only headers.
// Browsers never set either one on fetch()/XHR.
const forgedFetch =
(mode === "cors" || mode === "no-cors" || mode === "same-origin") &&
(h.has("sec-fetch-user") || h.has("upgrade-insecure-requests"));
// 3. The UA's OS disagrees with the client hint. Windows and Mac only:
// Android desktop-mode reports Linux, iOS WebKit sends no hints at all.
const platform = (h.get("sec-ch-ua-platform") ?? "").replace(/"/g, "").toLowerCase();
const platformMismatch =
platform !== "" &&
((ua.includes("Windows NT") && platform !== "windows") ||
(ua.includes("Macintosh") && platform !== "macos"));
The third one caught version three. Its UA said Windows NT 10.0 and its sec-ch-ua-platform said "macOS". Real Chrome derives both from the same place; UA reduction froze the version numbers, it didn't touch the OS. Android and iOS stay out of the check rather than getting guessed at. Eighteen tests cover this file and about half are negative controls taken from real user agents.
The second change mattered more. I had been treating this as one trust bit, first-party or not, which means copying the first-party shape once wins you unlimited upstream, and everything else shares a single pool the fleet can drain in any disguise it likes. Now each client kind gets its own hourly upstream bucket: official app, browser extension, cross-site webapp, address-bar navigation, plain script, suspect. Impersonating the app starves the app pool and nothing else. Total upstream is bounded by the sum of the buckets no matter which costume turns up.
What it costs
This is not a cryptographic boundary and I don't want it read as one. A script can produce headers that pass every check above; version two already did. What's gone is the free ride. Each rewrite now costs them a calibration pass against a real browser.
Real users do get caught. Someone behind an HTTP/1.1 proxy that strips Fetch Metadata lands in the suspect bucket. That's why suspect is a small pool rather than a 403: a self-contradiction is the only thing that earns a hard rejection, and a header that is merely absent never does. The page-proof path works the same way. A same-origin fetch that can't produce a Turnstile token isn't refused; it drops into an unproved pool, because Turnstile fails to load for plenty of real people.
The fleet's independent-IP count fell from around four thousand to roughly a hundred the next day, with no sign of it resurfacing in a new disguise. I read that as a retreat, not a win. Version four is coming, and the point of the buckets is that version four is capped before I've noticed it.
I build ipok.io, the IP checker all of this runs on.
Top comments (0)