Technical 12 min read

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.

FT
FineData Engineering · Editorial Policy
| | Updated July 28, 2026

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 (t for TCP), negotiated TLS version hint, SNI present (d) or not (i), cipher count, extension count, first/last characters of the first ALPN token (or 00)
  • 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:

ClientTypical TLS stackCipher / extension shapeALPNRelative uniqueness
Python requests / urllib3OpenSSL via Python sslFew suites, OpenSSL order, sparse modern extensionsOften missing or http/1.1 onlyVery low — one hash shared by huge bot fleets
Python httpx (default)Same OpenSSL path unless you swap backendsNearly identical to requests for TLSSimilar to requestsVery low
Plain curl (system OpenSSL/LibreSSL)Distro TLS libraryLibrary defaults, not Chrome/Firefox orderOften http/1.1 (and h2 if built with nghttp2)Low
curl-impersonateBrowser-tuned BoringSSL/NSS profilesCipher list, GREASE, extensions aligned to a named browser buildBrowser-like (h2, http/1.1)High (by design)
Go net/httpcrypto/tlsDistinct Go cipher and extension orderingh2, http/1.1 when HTTP/2 enabledMedium — recognizable Go fingerprint
Node.js httpsOpenSSL via NodeNode/OpenSSL defaults, not a browser buildDepends on http2 usageLow–medium
Chrome / FirefoxBoringSSL / NSSLarge suite lists, GREASE, browser-only extensions, rich supported_groups / sigalgsh2 firstHigh 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:

  1. Known-bot JA3/JA4 — exact matches for library defaults.
  2. UA / TLS mismatch — Chrome User-Agent with a Python OpenSSL fingerprint.
  3. Missing browser extensions — no GREASE, no key_share groups browsers advertise, no certificate compression, and so on.
  4. 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:

  1. Accept a raw TLS record on TCP (no certificate needed — you only parse the first flight).
  2. Decode ClientHello.
  3. Emit JA3 string + MD5, and JA4 a_b_c.
  4. 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.x as 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 h2 the way browsers do. JA4 surfaces that in the a suffix (00 vs h2).
  • Treat impersonation as a moving target. curl-impersonate and 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

  1. Run the listener on a host you administer.
  2. Capture JA3/JA4 for every client your organization ships (CI bots, SDKs, browsers).
  3. Store the hashes next to library versions — upgrades change fingerprints.
  4. Alert when production egress suddenly matches a known library JA3 while claiming to be a browser.
  5. 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.

#tls-fingerprinting #ja3 #ja4 #http-clients #security #python

Related Articles