Two clients, same residential IP, same User-Agent header. This is what the server actually sees:
Chrome 58 1:65536;3:1000;4:6291456|15663105|0
Go-http-client 2:0;4:4194304;6:10485760|1073741824|0
Those are HTTP/2 fingerprints in the notation Akamai proposed at Black Hat EU 2017: SETTINGS parameters, WINDOW_UPDATE increment, PRIORITY frames. Your library picks all of it before your code touches the request.
One of those gets a 200. The other gets a challenge page. The address is identical in both cases, so whatever is happening, the address is not the variable.
The score is not computed from the address
There is no registry where an IP's reputation lives. There are commercial scoring APIs, and their output is what people mean by "clean" and "burned." IPQualityScore documents theirs:
fraud_score "The overall fraud score of the user based on the IP,
user agent, language, and any other optionally passed
variables."
recent_abuse verified abuse across their network, window given only
as "the past few days"
abuse_velocity high | medium | low | none
connection_type Residential | Corporate | Education | Mobile | Data Center
The vendor's own definition of an IP reputation score takes your User-Agent and your Accept-Language as inputs. It was never a measurement of the address alone and never claimed to be.
IPQS suggests 75 as suspicious and 90 as high risk. Every site using it picks its own threshold anyway, so a block is a private cutoff applied to a proprietary score built partly from headers you chose. Three separate decisions, one of which is yours.
The clearest case is one where nothing happened
Spamhaus runs the XBL for addresses observed doing something, and the PBL for something else entirely:
a dataset containing end-user IP address ranges from which email should never be sent directly to the final destination
Networks add and maintain many of those ranges themselves. Your home broadband address is very likely listed right now, submitted by your own ISP, with no incident behind it and nothing to clear. Category membership is the whole listing.
Proxy classification works the same way more often than people assume. An address is flagged for belonging to a range, and the range was classified months before your traffic existed.
The HTTP/2 layer
Implementations disagree about which SETTINGS parameters to send, in what order, with what values, about WINDOW_UPDATE increments, and about whether to send PRIORITY frames for streams that do not exist yet. Almost none of it is configurable.
Chrome 58 1:65536;3:1000;4:6291456|15663105|0
Edge 14 3:1024;4:10485760|10420225|0
Go-http-client 2:0;4:4194304;6:10485760|1073741824|0
curl 7.54 3:100;4:1073741824;2:0|1073676289|0
Pseudo-header order splits the browsers on its own:
Chrome :method :authority :scheme :path
Firefox :method :path :authority :scheme
Safari :method :scheme :path :authority
A Go program claiming to be Chrome sends 2:0;4:4194304;6:10485760. The header is a string you set. The SETTINGS frame is your library's opinion, transmitted before request headers exist.
The TLS layer
JA3 hashed the Client Hello into one opaque value, which meant flipping a single cipher produced an entirely different hash. JA4 made it modular instead:
JA4 = a _ b _ c
a TLS version, client library, connection type
b ciphers
c extensions and ALPN
FoxIO's writeup gives the case that matters. A GreyNoise-tracked actor rotated one cipher at a time and generated a flood of distinct JA3 hashes. Under JA4 only b moved. The a and c segments held, and the actor stayed trackable on a partial fingerprint.
Detection does not need your whole fingerprint to match something known. It needs one stable substring.
Check what you are presenting
# pip install "httpx[http2]"
import httpx
PROXY = "http://user:pass@gateway.example.net:8000"
with httpx.Client(proxy=PROXY, http2=True, timeout=30) as client:
data = client.get("https://tls.peet.ws/api/all").json()
print("Exit IP: ", data["ip"])
print("JA4: ", data["tls"]["ja4"])
# the http2 object is only present if the connection negotiated h2
h2 = data.get("http2")
print("HTTP/2 fp:", h2["akamai_fingerprint"] if h2 else "fell back to HTTP/1.1")
If that prints the fallback, stop there. A client advertising Chrome while negotiating HTTP/1.1 against a host offering h2 is not a subtle inconsistency.
Otherwise, run the same request through curl_cffi with impersonate="chrome124" and diff the two. Same proxy, same exit IP, two unrelated identities on the wire. If the site you are fighting treats them differently, the pool was never the problem.
Agreement across layers is the actual signal
The most useful line in the Akamai paper is nearly an aside. A TCP fingerprint indicating Windows alongside an HTTP/2 fingerprint indicating Chrome on macOS implies an intermediary, because those parameters are not independently tunable in most implementations.
JA4+ is built on the same premise, publishing separate fingerprints for TLS, HTTP behavior, and network latency and TTL so a defender can correlate them and notice disagreement.
Nobody is hunting for a bad IP. They are looking for a stack that fails to agree with itself. Residential address in Ohio, Python TLS build, Go HTTP/2 frames, Chrome/131 in the headers, TTL suggesting a datacenter three hops out. That contradiction survives any improvement to the address.
What the address still decides
ASN classification is real and unspoofable. If a site drops known hosting ranges, a flawless Chrome fingerprint out of AWS still gets nothing. Subnet neighbors matter, since scoring works at range granularity and a bad /24 drags everything in it down. Concurrency per address is its own tell, because eight simultaneous sessions from one residential line is not a shape residential traffic takes.
The address sets a floor and the client stack sets the ceiling. Most teams spend on the floor, leave the ceiling at library defaults, and conclude the proxies are bad.
Where to start
Fingerprint yourself. It costs one request and it is frequently the entire answer. If JA4 says Python and the HTTP/2 frames say Go while your headers say Chrome, fix that before touching anything else.
Pacing and concurrency per address come second, and that is the one most often misdiagnosed as a reputation problem.
Pool quality comes third. When you do get there, pool size is the wrong question. Ask how addresses are acquired and expect a specific answer about consent rather than the phrase "ethically sourced," ask what the ASN spread looks like, and ask whether sessions hold long enough not to migrate mid-flow, since a migrating session manufactures exactly the cross-layer contradiction described above. Providers built on residential and mobile pools, 2Extract among them, can answer those directly, and deflection on sourcing is itself information. None of them can fix your client stack.
"IP reputation" is a comfortable phrase because it locates the problem outside your code. Usually it is inside it.
Top comments (0)