Use case
Tell automation from real users at the TLS layer — before your app parses a request, checks a password, or spends a database query. The fingerprint arrives in the first packet of every connection; this turns it into a decision.
The signal
The corpus is gathered inside a residential proxy network, so a fingerprint seen here is software that routes through anonymising proxies — and ranges across many domains doing it. That population is disproportionately scrapers, checkout bots, credential stuffing. Browsers are the exception: they have an honest reason to be proxied (a VPN) and they run JavaScript, so you can challenge them. Everything else — curl, Go's net/http, headless stacks — can't pass a JS challenge and has no honest reason to be in a proxy pool at volume.
One call: /verdict
Hand it the base64 of a raw ClientHello record; get back the fingerprint and one decision. No detail to sift, unlike the rest of the API — a gate has to decide fast.
curl -s https://api.tls-reputation.com/api/v1/verdict \
-H 'content-type: application/json' \
-d '{"client_hello":"<base64 ClientHello>"}'{
"ja4": "t13d1516h2_8daaf6152771_b0da82dd1658",
"verdict": "allow",
"known": "Google Chrome",
"browser": true,
"observed": true,
"reason": "recognised Google Chrome"
}| verdict | when | do |
|---|---|---|
| allow | recognised client, or browser-shaped (carries the post-quantum key share almost only browsers send) | let through |
| challenge | unrecognised, or seen but not clearly ranging | JS challenge / CAPTCHA |
| deny | unrecognised and reaching many domains through the proxy network | block or hard-challenge |
A reverse-proxy gate
Put a thin reverse proxy in front of your origin. It peeks the ClientHello before the TLS handshake consumes it, asks /verdict once per session, and pins the answer to the connection's JA4 in a signed cookie. Every later request rides the cookie — no API call — and the origin just reads a header.
on each request:
hello = peek_client_hello(conn) # MSG_PEEK, before the TLS handshake
ja4 = ja4(hello) # computed at the gate, not asserted
if cookie.valid and cookie.ja4 == ja4:
decision = cookie.verdict # cached — no API call this request
else:
decision = POST /verdict {hello} # once per session
set_cookie(sign({ ja4, decision }))# JWT, pinned to ja4, stateless
if decision == "deny": block
if decision == "challenge": js_challenge() # a real browser passes; headless doesn't
forward(origin, add_headers = {
"X-TLS-JA4": ja4,
"X-TLS-Verdict": decision,
})The cookie is a JWT, so the gate keeps no state; and it is bound to the JA4 the gate recomputes each request, so a cookie lifted onto another client fails the check and gets re-judged — it isn't portable. JA4 (not JA3) because it stays constant across the ClientHello shuffling browsers do, so one browser keeps one JA4 for the session and the cache actually hits.
A working single-file PoC of exactly this lives in the project repo under gate/.
The honest limit
A fingerprint is not a secret. A JA4 is the same for everyone on a given Chrome build, and a determined client can spoof a browser's ClientHello and earn an allow. That's the point of the layering: the fingerprint is the cheap first filter that decides who to challenge, and the JS challenge is what a forged ClientHello still can't get past. The verdict is a signal, never the last word — see the idea and your own fingerprint.