JA3/JA4 TLS Fingerprints Across HTTP Clients
Compare JA3 and JA4 TLS fingerprints across requests, curl, Go, Node, and browsers. Capture your own hashes with a runnable Python script.
JA3/JA4 TLS Fingerprints Across HTTP Clients
Before an HTTPS client sends a single HTTP header, it has already advertised its identity. The TLS ClientHello carries cipher suites, extensions, elliptic curves, signature algorithms, and ALPN preferences in an order and combination that varies by stack. Anti-bot systems hash that advertisement into JA3 and JA4 fingerprints and treat many library defaults as non-browser traffic.
This piece is a methodology you can run yourself: a short comparison of how common HTTP clients construct ClientHello, plus a complete Python listener that prints JA3 and JA4 for any client that connects to your host. For the conceptual background of how defenders use these hashes, read the companion explainer TLS Fingerprinting Explained. For how fingerprint checks sit next to headers, JS challenges, and behavioral signals, see anti-bot detection layers.
What JA3 and JA4 Hash (Briefly)
JA3 (Salesforce) builds a comma-separated string from five ClientHello fields, then MD5-hashes it:
TLSVersion,CipherSuites,Extensions,EllipticCurves,ECPointFormats
Lists inside each field use dash-separated decimal IANA values. GREASE values are ignored. The result is a 32-character hex string that is stable for a given TLS library and defaults — and therefore easy to put on a denylist.
JA4 (FoxIO) is a three-part fingerprint a_b_c:
- a — readable prefix: transport (
tfor TCP), negotiated TLS version hint, SNI present (d) or not (i), cipher count, extension count, first/last characters of the first ALPN token (or00) - b — truncated SHA-256 of cipher suites sorted as lowercase hex
- c — truncated SHA-256 of sorted extensions (excluding SNI and ALPN) plus signature algorithms in wire order
JA4 sorts ciphers and extensions before hashing, so simple order randomization that once made JA3 unstable does not change b/c. Browsers still stand out because their sets of suites, groups, and extensions differ from OpenSSL defaults in requests, stock curl, Go crypto/tls, and Node’s OpenSSL binding.
You do not need anyone else’s capture database to learn this. Point clients at a listener you control and compare the hashes side by side.
How Clients Differ in Practice
The differences are structural, not cosmetic. Approximate profiles when each client uses stock settings against a normal HTTPS endpoint:
| Client | Typical TLS stack | Cipher / extension shape | ALPN | Relative uniqueness |
|---|---|---|---|---|
Python requests / urllib3 | OpenSSL via Python ssl | Few suites, OpenSSL order, sparse modern extensions | Often missing or http/1.1 only | Very low — one hash shared by huge bot fleets |
Python httpx (default) | Same OpenSSL path unless you swap backends | Nearly identical to requests for TLS | Similar to requests | Very low |
Plain curl (system OpenSSL/LibreSSL) | Distro TLS library | Library defaults, not Chrome/Firefox order | Often http/1.1 (and h2 if built with nghttp2) | Low |
curl-impersonate | Browser-tuned BoringSSL/NSS profiles | Cipher list, GREASE, extensions aligned to a named browser build | Browser-like (h2, http/1.1) | High (by design) |
Go net/http | crypto/tls | Distinct Go cipher and extension ordering | h2, http/1.1 when HTTP/2 enabled | Medium — recognizable Go fingerprint |
Node.js https | OpenSSL via Node | Node/OpenSSL defaults, not a browser build | Depends on http2 usage | Low–medium |
| Chrome / Firefox | BoringSSL / NSS | Large suite lists, GREASE, browser-only extensions, rich supported_groups / sigalgs | h2 first | High diversity across versions, still browser-shaped |
Public references often cite a well-known JA3 for default Python requests (for example eb22cb93e4e72e23d8050e20f60ef68f appears in many open write-ups). Treat any hash below as illustrative of what your own run will print, not as a FineData lab measurement against an external target. Re-run the script after OS or library upgrades — OpenSSL and browser builds change the wire format.
What anti-bot systems care about:
- Known-bot JA3/JA4 — exact matches for library defaults.
- UA / TLS mismatch — Chrome User-Agent with a Python OpenSSL fingerprint.
- Missing browser extensions — no GREASE, no
key_sharegroups browsers advertise, no certificate compression, and so on. - Fleet monoculture — millions of IPs sharing one JA3 is a stronger signal than the hash alone.
None of that requires looking at third-party commercial sites. A listener on 127.0.0.1 is enough to see why detection works.
Methodology: Capture ClientHello on a Host You Control
Goals:
- Accept a raw TLS record on TCP (no certificate needed — you only parse the first flight).
- Decode
ClientHello. - Emit JA3 string + MD5, and JA4
a_b_c. - Drive different clients at that port and record the fingerprints.
Use a machine you own, a local process, or a staging TLS front door. Do not point the experiment at infrastructure you are not authorized to probe. For a harmless public HTTPS GET after you understand fingerprints, https://httpbin.org/get is a common sandbox — but the fingerprint math happens on the ClientHello your client sends to whatever peer you choose; the script below captures that peer as your listener.
Runnable Python Script: Local JA3/JA4 Listener
Save as ja3_ja4_listener.py. Dependencies: Python 3.10+ only (stdlib). Run with sudo only if you bind a privileged port; 8443 needs no root.
#!/usr/bin/env python3
"""
Local TLS ClientHello JA3/JA4 listener.
Bind a TCP port, accept one connection, parse the first TLS handshake
record as ClientHello, print JA3 + JA4, then close.
Usage:
python3 ja3_ja4_listener.py --host 127.0.0.1 --port 8443
Then, in another terminal, aim any HTTPS client at https://127.0.0.1:8443/
(certificate errors are expected — the listener never completes the handshake).
"""
from __future__ import annotations
import argparse
import hashlib
import socket
import struct
import sys
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
def is_grease(value: int) -> bool:
return (value & 0x0F0F) == 0x0A0A and ((value >> 8) & 0xFF) == (value & 0xFF)
def u8(buf: memoryview, off: int) -> Tuple[int, int]:
return buf[off], off + 1
def u16(buf: memoryview, off: int) -> Tuple[int, int]:
return struct.unpack_from("!H", buf, off)[0], off + 2
def take(buf: memoryview, off: int, n: int) -> Tuple[memoryview, int]:
return buf[off : off + n], off + n
@dataclass
class ClientHello:
legacy_version: int = 0
cipher_suites: List[int] = field(default_factory=list)
extensions: List[int] = field(default_factory=list)
supported_groups: List[int] = field(default_factory=list)
ec_point_formats: List[int] = field(default_factory=list)
signature_algorithms: List[int] = field(default_factory=list)
alpn_protocols: List[bytes] = field(default_factory=list)
has_sni: bool = False
supported_versions: List[int] = field(default_factory=list)
def parse_client_hello(data: bytes) -> ClientHello:
buf = memoryview(data)
if len(buf) < 5:
raise ValueError("short TLS record")
content_type, off = u8(buf, 0)
_rec_ver, off = u16(buf, off)
rec_len, off = u16(buf, off)
if content_type != 22: # handshake
raise ValueError(f"expected handshake record, got type={content_type}")
record, off = take(buf, off, rec_len)
hs_type, o = u8(record, 0)
hs_len = (record[1] << 16) | (record[2] << 8) | record[3]
o = 4
body, _ = take(record, o, hs_len)
if hs_type != 1:
raise ValueError(f"expected ClientHello (1), got {hs_type}")
ch = ClientHello()
o = 0
ch.legacy_version, o = u16(body, o)
o += 32 # random
sid_len, o = u8(body, o)
o += sid_len
cs_len, o = u16(body, o)
cs_raw, o = take(body, o, cs_len)
for i in range(0, cs_len, 2):
suite = struct.unpack_from("!H", cs_raw, i)[0]
if not is_grease(suite):
ch.cipher_suites.append(suite)
comp_len, o = u8(body, o)
o += comp_len
if o >= len(body):
return ch
ext_total, o = u16(body, o)
ext_end = o + ext_total
while o + 4 <= ext_end:
etype, o = u16(body, o)
elen, o = u16(body, o)
edata, o = take(body, o, elen)
if is_grease(etype):
continue
ch.extensions.append(etype)
if etype == 0x0000: # server_name
ch.has_sni = elen > 0
elif etype == 0x000A: # supported_groups
gl, g = u16(edata, 0)
for i in range(0, gl, 2):
gval = struct.unpack_from("!H", edata, 2 + i)[0]
if not is_grease(gval):
ch.supported_groups.append(gval)
elif etype == 0x000B: # ec_point_formats
n, p = u8(edata, 0)
for i in range(n):
ch.ec_point_formats.append(edata[p + i])
elif etype == 0x000D: # signature_algorithms
sl, s = u16(edata, 0)
for i in range(0, sl, 2):
sval = struct.unpack_from("!H", edata, 2 + i)[0]
if not is_grease(sval):
ch.signature_algorithms.append(sval)
elif etype == 0x0010: # ALPN
_tl, a = u16(edata, 0)
while a < len(edata):
plen, a = u8(edata, a)
proto, a = take(edata, a, plen)
ch.alpn_protocols.append(bytes(proto))
elif etype == 0x002B: # supported_versions
n, v = u8(edata, 0)
for i in range(0, n, 2):
ver = struct.unpack_from("!H", edata, v + i)[0]
if not is_grease(ver):
ch.supported_versions.append(ver)
return ch
def ja3_md5(ch: ClientHello) -> Tuple[str, str]:
parts = [
str(ch.legacy_version),
"-".join(str(c) for c in ch.cipher_suites),
"-".join(str(e) for e in ch.extensions),
"-".join(str(g) for g in ch.supported_groups),
"-".join(str(p) for p in ch.ec_point_formats),
]
ja3_str = ",".join(parts)
return ja3_str, hashlib.md5(ja3_str.encode("ascii")).hexdigest()
def _tls_version_label(ch: ClientHello) -> str:
# Prefer highest non-GREASE supported_versions; else legacy_version.
mapping = {
0x0304: "13",
0x0303: "12",
0x0302: "11",
0x0301: "10",
0x0300: "s3",
}
if ch.supported_versions:
best = max(ch.supported_versions)
return mapping.get(best, "00")
return mapping.get(ch.legacy_version, "00")
def _alpn_chars(ch: ClientHello) -> str:
if not ch.alpn_protocols:
return "00"
first = ch.alpn_protocols[0]
# First and last alphanumeric ASCII chars; else hex of the value.
alnum = [chr(b) for b in first if chr(b).isalnum()]
if len(alnum) >= 2:
return alnum[0] + alnum[-1]
if len(alnum) == 1:
return alnum[0] + alnum[0]
hx = first.hex()
return (hx[0] + hx[-1]) if hx else "00"
def ja4(ch: ClientHello) -> str:
ciphers = [c for c in ch.cipher_suites if not is_grease(c)]
exts = [e for e in ch.extensions if not is_grease(e)]
proto = "t"
ver = _tls_version_label(ch)
sni = "d" if ch.has_sni else "i"
c_count = min(len(ciphers), 99)
e_count = min(len(exts), 99)
alpn = _alpn_chars(ch)
a = f"{proto}{ver}{sni}{c_count:02d}{e_count:02d}{alpn}"
cipher_hex = ",".join(f"{c:04x}" for c in sorted(ciphers))
b = hashlib.sha256(cipher_hex.encode("ascii")).hexdigest()[:12]
# Exclude SNI (0000) and ALPN (0010) from hash input per JA4 spec.
ext_for_hash = [e for e in exts if e not in (0x0000, 0x0010)]
ext_hex = ",".join(f"{e:04x}" for e in sorted(ext_for_hash))
if ch.signature_algorithms:
sig_hex = ",".join(f"{s:04x}" for s in ch.signature_algorithms)
c_input = f"{ext_hex}_{sig_hex}" if ext_hex else f"_{sig_hex}"
else:
c_input = ext_hex
if not c_input or c_input == "_":
c = "000000000000"
else:
c = hashlib.sha256(c_input.encode("ascii")).hexdigest()[:12]
return f"{a}_{b}_{c}"
def recv_client_hello(conn: socket.socket, timeout: float = 10.0) -> bytes:
conn.settimeout(timeout)
header = b""
while len(header) < 5:
chunk = conn.recv(5 - len(header))
if not chunk:
raise ConnectionError("peer closed before TLS header")
header += chunk
rec_len = struct.unpack("!H", header[3:5])[0]
body = b""
while len(body) < rec_len:
chunk = conn.recv(rec_len - len(body))
if not chunk:
raise ConnectionError("peer closed mid-record")
body += chunk
return header + body
def serve_once(host: str, port: int) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
sock.listen(1)
print(f"listening on {host}:{port} — connect any TLS client now", flush=True)
conn, addr = sock.accept()
with conn:
print(f"connection from {addr[0]}:{addr[1]}", flush=True)
raw = recv_client_hello(conn)
ch = parse_client_hello(raw)
ja3_str, ja3_hash = ja3_md5(ch)
print("JA3_STRING", ja3_str)
print("JA3", ja3_hash)
print("JA4", ja4(ch))
print("ciphers", len(ch.cipher_suites), [f"{c:04x}" for c in ch.cipher_suites[:8]], "...")
print("extensions", [f"{e:04x}" for e in ch.extensions])
print("groups", [f"{g:04x}" for g in ch.supported_groups])
print("alpn", [p.decode("ascii", "replace") for p in ch.alpn_protocols])
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(description="Capture one ClientHello and print JA3/JA4")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8443)
args = p.parse_args(argv)
try:
serve_once(args.host, args.port)
except Exception as exc: # noqa: BLE001 — CLI tool
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Drive Clients Against the Listener
Terminal A:
python3 ja3_ja4_listener.py --host 127.0.0.1 --port 8443
Terminal B — restart the listener between each client:
# Python requests
python3 -c "import requests; requests.get('https://127.0.0.1:8443/', verify=False, timeout=5)"
# httpx
python3 -c "import httpx; httpx.get('https://127.0.0.1:8443/', verify=False, timeout=5)"
# stock curl
curl -sk https://127.0.0.1:8443/ -o /dev/null
# curl-impersonate (if installed) — Chrome-shaped ClientHello
curl_chrome116 -sk https://127.0.0.1:8443/ -o /dev/null
# Go
cat > /tmp/tlsprobe.go <<'EOF'
package main
import ("crypto/tls"; "net/http"; "time")
func main() {
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
c := &http.Client{Transport: tr, Timeout: 5 * time.Second}
c.Get("https://127.0.0.1:8443/")
}
EOF
go run /tmp/tlsprobe.go
# Node.js
node -e "require('https').get('https://127.0.0.1:8443/',{rejectUnauthorized:false},r=>r.resume()).on('error',()=>{})"
For a real browser: open https://127.0.0.1:8443/ and accept the certificate warning (or install a local CA). The listener exits after one connection; start it again for each trial.
Illustrative Output Shape
When you run the script yourself, expect lines like:
listening on 127.0.0.1:8443 — connect any TLS client now
connection from 127.0.0.1:54321
JA3_STRING 771,4865-4866-4867-...,0-11-10-...,29-23-24,0
JA3 eb22cb93e4e72e23d8050e20f60ef68f
JA4 t13d0310h1_aaaaaaaaaaaa_bbbbbbbbbbbb
ciphers 3 ['1301', '1302', '1303'] ...
extensions ['0000', '000b', '000a', ...]
groups ['001d', '0017', '0018']
alpn ['http/1.1']
The JA3 value above is the publicly documented default-requests fingerprint often quoted in open literature; your OpenSSL build may differ. Chrome/Firefox runs typically show more cipher suites, GREASE-filtered extension counts in the teens, ALPN starting with h2, and a JA4 a prefix resembling t13d1516h2. Stock curl and Go land between those poles. The educational point is the gap between library defaults and browsers, which you can reproduce on any laptop.
Reading the Diff Like a Defender
Once you have a table of your own hashes:
- Cluster by stack, not by IP. Ten scrapers on ten residential IPs with one JA3 still look like one bot family.
- Correlate with HTTP. If JA4 looks like Chrome but the request uses
python-requests/2.xas User-Agent (or a shuffled header order browsers never emit), the TLS layer and the application layer disagree. - Watch ALPN and HTTP/2. Many library clients either omit ALPN or never speak
h2the way browsers do. JA4 surfaces that in theasuffix (00vsh2). - Treat impersonation as a moving target.
curl-impersonateand similar tools raise TLS fidelity; defenders then weigh JA4 together with HTTP/2 SETTINGS, header order, and JavaScript challenges — covered in the anti-bot detection overview.
If you want fingerprint diversity and browser-grade TLS handled behind an API rather than maintained per language stack, the FineData API documentation describes POST https://api.finedata.ai/api/v1/scrape with flags such as use_antibot and stealth_antibot. Auth uses the x-api-key header. That path is optional; the listener above stands alone for learning.
Operational Checklist for Your Lab
- Run the listener on a host you administer.
- Capture JA3/JA4 for every client your organization ships (CI bots, SDKs, browsers).
- Store the hashes next to library versions — upgrades change fingerprints.
- Alert when production egress suddenly matches a known library JA3 while claiming to be a browser.
- Never treat a single hash as proof of malice; combine TLS with application-layer consistency checks.
Summary
JA3 and JA4 turn the cleartext ClientHello into compact identifiers. Default requests, httpx, stock curl, Go net/http, and Node https advertise different cipher lists, extension sets, curves, and ALPN values than Chrome or Firefox — which is exactly how anti-bot systems separate libraries from browsers. Capture those differences yourself with the listener above against clients you control; keep the experiment first-party, reproducible, and free of unverifiable claims about third-party targets. Pair the numbers with the conceptual model in TLS Fingerprinting Explained when you design defensive monitoring or client policy.
Related Articles
HTTP/2 Fingerprinting: Capture Client Frame Signatures
Capture HTTP/2 fingerprints from SETTINGS, WINDOW_UPDATE, PRIORITY, and pseudo-header order using a local Python listener you control.
TechnicalTCP/IP Fingerprinting: OS Clues in the SYN Packet
Capture TCP/IP fingerprints from SYN fields — TTL, window, MSS, option order, DF — with a scapy sniffer on your host. See why the OS stack leaks below TLS.
TechnicalTLS Fingerprinting Explained: How Anti-Bot Systems Detect Scrapers
TLS fingerprinting explained: how JA3/JA4 hashes let Cloudflare, Akamai, and other anti-bot systems detect scrapers from the TLS client hello alone.