ja3lab
v0.2.2
Published
What does your HTTP client's TLS fingerprint actually look like — and is it coherent with your User-Agent? JA3/JA4/HTTP2 fingerprint check, diff, and classification for anti-bot work.
Maintainers
Readme
ja3lab
What does your HTTP client's TLS fingerprint actually look like — and does it match the story your User-Agent tells?
$ ja3lab compare --via "curl_chrome150"
── ja3lab compare ──
this node client curl_chrome150
────────────────────────────────────────────────────────────────────────────────────────────────
User-Agent node != Mozilla/5.0 (...) Chrome/150.0.0.0
JA3 hash d67b094811e5145139d7cea5f014309f != 8817cca1f070ba0a1b31f7c76e1c1ed1
JA4 t13d5212h1_b262b3658495_8e6e362c5e != t13d1516h2_8daaf6152771_806a8c22fd
HTTP/2 (h2) — (HTTP/1.x only) != 52d84b11737d980aef856699f885ca86
────────────────────────────────────────────────────────────────────────────────────────────────
The two clients present DIFFERENT TLS fingerprints. Anti-bot reads the handshake, not the UA.Why
Anti-bot doesn't read your User-Agent or your TLS fingerprint. It reads both and asks whether they tell the same story. A Chrome UA over a Node/OpenSSL handshake — 59 cipher suites where a real browser offers 15 — is the single most common scraper tell, and it's invisible unless you look at the handshake.
ja3lab makes the handshake visible:
- check — what do I look like right now, to a WAF?
- compare — my client vs a real browser (via curl-impersonate), side by side.
- known — a reference table of verified fingerprints, captured live, not copied from blogs.
Install
npm i -g ja3labOr from source:
git clone https://github.com/Oussama-Rahmouni/ja3lab.git
cd ja3lab
npm install
npm run build
npm link # puts `ja3lab` on your PATHUsage
ja3lab check # fingerprint this Node client
ja3lab check --via "curl_chrome150" # fingerprint any command-line client
ja3lab compare --via "curl_chrome150" # side-by-side diff
ja3lab known # the verified reference table
ja3lab check --json # machine-readable
ja3lab check --reflector https://my-reflector/api/all # self-hosted echo endpointExit codes: 0 coherent · 2 UA and TLS disagree · 1 error. Drops into CI:
fail the build when your scraper's fingerprint drifts off the browser it claims to be.
What the verdict means
INCOHERENT — UA claims chrome116, the handshake disagrees:
· UA claims chrome but the handshake offers 59 cipher suites — a real browser offers ~15–17
· UA claims chrome but ALPN is h1 — modern browsers negotiate h2 on TLS 1.3- browser-shaped / library-shaped — structural classification from the JA4 shape: cipher count, extension count, ALPN. Browsers are minimal and deliberate; TLS libraries ship the full cipher list.
- COHERENT / INCOHERENT — whether the UA claim and the handshake agree. Incoherence is what gets you flagged before any behavioral analysis runs.
The reference table
Captured live against tls.peet.ws (2026-09-16, expanded 2026-09-24 and 2026-10-10):
| label | client | JA4 | the tell |
|---|---|---|---|
| chrome-150 | Chrome 150 via curl-impersonate 2.x (BoringSSL) | t13d1516h2_8daaf6152771_… | same JA4_a/b as chrome-116 — only the extension set moved in 34 versions |
| chrome-116 | Chrome 116 via curl-impersonate (BoringSSL) | t13d1516h2_… | 15 ciphers, 16 extensions, h2 |
| node-undici | Node fetch / undici (18–20) | t13d5911h1_… | 59 ciphers, h1 |
| node-undici-22 | Node 22 fetch / undici | t13d5212h1_… | 52 ciphers — same family, new shape |
| curl-openssl | curl 8.5 (OpenSSL) | t13d3112h2_e8f1e7e78f70_… | OpenSSL default list |
| python-requests | Python requests 2.31 (urllib3) | t13d3112h1_e8f1e7e78f70_… | byte-identical JA3 to curl — only ALPN differs |
| python-urllib | Python 3.12 urllib (OpenSSL) | t13d3113h1_e8f1e7e78f70_… | same JA4_a cipher hash as curl |
| wget-gnutls | wget 1.21 (GnuTLS) | t13d7511_… | 75 ciphers, no ALPN at all |
| go-nethttp | Go net/http (crypto/tls) | t13d1411h2_… | 14 ciphers, h2 — passes the browser-shape heuristic |
The lesson rows: curl, requests, and urllib differ in everything except the TLS library — and the TLS library is what the WAF sees. And the Go row is the warning: cipher-count heuristics alone can't separate a minimal TLS library from a browser.
Library
import { fingerprintSelf, fingerprintVia, classify, checkCoherence, renderCompare } from 'ja3lab';
const me = await fingerprintSelf();
const browser = await fingerprintVia('curl_chrome116');
classify(me); // exact match + browser/library shape + reasons
checkCoherence(me); // does the UA story match the handshake?
renderCompare(me, browser);What it is not
Not a bypass tool. It shows you the fingerprint; fixing it is a separate craft (curl-impersonate, uTLS, browser clients). Diagnosis first.
License
MIT · Oussama Rahmouni
