liarjs.dev
← Field notes
Network layer

The layer JavaScript never sees: TLS, JA3/JA4 and HTTP/2 fingerprints

October 10, 2026 · 10 min read

Every anti-detect debate happens in the browser: canvas noise, WebGL vendor strings, the navigator.plugins array. By the time any of that JavaScript runs, the client has already sent a TLS ClientHello and, on HTTP/2, a sequence of SETTINGS and WINDOW_UPDATE frames. Those bytes are a fingerprint. The server computes them at the transport layer, and no amount of JS spoofing can reach back to change them. This is the structural blind spot of every JS-layer anti-detect kernel. Run a genuine Chromium build and it becomes a quiet asset.

The TLS/JA3/JA4 and HTTP/2 fingerprint that lands before any JavaScript runs

What the network layer actually fingerprints

Three distinct fingerprints live below the JavaScript runtime, and people conflate them constantly. They are not the same thing, and they are not computed the same way.

TLS ClientHello (JA3 / JA3N / JA4). The first flight of a TLS handshake is a ClientHello: the client advertises its supported TLS version, cipher suites, extensions, elliptic curves (supported groups) and EC point formats. The exact set, and the exact order of those values, falls out of whichever TLS library built the message. JA3 (Salesforce, 2017) concatenates five of those fields into a comma/dash-delimited string and MD5s it. JA3N is the order-normalized variant. It exists because Chrome began randomizing extension order (GREASE and deliberate shuffling), which made raw JA3 unstable across otherwise-identical clients. JA4, from John Althouse’s FoxIO (2024), is the structured successor. Instead of one opaque MD5 it emits a human-legible, sectioned fingerprint: protocol, SNI presence, cipher count, extension count, ALPN, plus truncated SHA-256 hashes of the sorted cipher and extension lists. Because JA4 sorts before hashing, it survives Chrome’s randomization where plain JA3 flaps.

HTTP/2 frame fingerprint (Akamai HTTP2). Once the encrypted connection is up and ALPN negotiates h2, the client opens with an HTTP/2 SETTINGS frame, a WINDOW_UPDATE and a set of stream priorities. The values (SETTINGS_HEADER_TABLE_SIZE, SETTINGS_INITIAL_WINDOW_SIZE, SETTINGS_MAX_CONCURRENT_STREAMS, the connection-level window increment) plus the order the pseudo-headers appear in (:method, :authority, :scheme, :path) are again library-specific. Akamai’s well-known format hashes these into a compact string. curl, Go’s net/http, Python’s httpx and Chrome each carry a distinct and stable HTTP/2 signature.

HTTP header fingerprint (JA4H). Above that, the actual request headers form JA4H: method, HTTP version, the names and order of headers, cookie presence and Accept-Language. The order Chrome emits headers in is not the order a hand-rolled requests call or a mis-configured proxy produces. Per the Bamberg dataset (below), header-layer signals alone are enough to block the majority of headless Chromium.

JA3 / JA3N JA4 JA4H
Layer TLS ClientHello TLS ClientHello HTTP request
Inputs version, ciphers, extensions, curves, EC formats protocol, SNI, cipher/ext counts, ALPN + sorted hashes method, version, header names/order, cookies, Accept-Language
Output 32-char MD5 sectioned string + SHA-256 truncations sectioned string + hashes
Order-robust JA3 no / JA3N yes yes (sorts before hashing) order is a signal
Author / era Salesforce, 2017 FoxIO / John Althouse, 2024 FoxIO, 2024
Reachable from JS no no no

Anatomy of a JA3 string

JA3 is five fields, in this order, joined by commas; within a field, values are dash-delimited. The whole string then gets MD5’d. The MD5 is what gets logged and matched, but the pre-image is where the signal lives.

JA3 pre-image (Chrome-like ClientHello):

  771,          # TLSVersion            (0x0303 = TLS 1.2 record version)
  4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,
                # CipherSuites          (order matters for JA3, not JA3N)
  0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-21,
                # Extensions            (GREASE values stripped before hashing)
  29-23-24,     # SupportedGroups       (elliptic curves: X25519, secp256r1, secp384r1)
  0             # ECPointFormats        (uncompressed)

  => MD5 => cd08e31494f9531f560d64c695473da9   # the "JA3 hash"

No single field is the tell. It’s the joint distribution. A real Chrome 149 build emits a cipher list, an extension set (including the modern ones: signature_algorithms 13, key_share 51, supported_versions 43, application_settings 17513, encrypted_client_hello 65037 when enabled) and a supported-groups list that all cohere. A Python client built on OpenSSL emits a different cipher order, a smaller extension set and no GREASE. The MD5 collapses all of that into one value a WAF can match against a known-Chrome table in constant time.

Why JavaScript can never fix it

Point a spoofing kernel at tls.peet.ws/api/all, BrowserLeaks TLS or ja3.zone and watch what the page does. It fetches. The JA3, JA3N, JA4, the Akamai HTTP/2 hash and JA4H all get computed by the server from the connection that carried the request, then handed back as JSON for the page to render.

We confirmed this directly. In an instrumented recon of 32 fingerprinting and bot-detection sites (2026-07-14), an engine-layer log recorded every Web IDL API each site touched. The TLS/network sites read essentially nothing through JavaScript. tls.peet.ws logged zero fingerprinting-dimension API accesses. BrowserLeaks TLS logged three (its tls.js just fetches the server result). ja3.zone made a handful of navigator reads. Compare that to CreepJS (1,056) or BrowserScan (12,375). There’s no getContext, no getParameter, no font enumeration, because there’s nothing for JS to measure. The measurement already happened one layer down, before the script tag parsed.

# The whole "JS side" of a TLS fingerprint check is one fetch:
curl -s https://tls.peet.ws/api/all | jq '{
  ja3:        .tls.ja3_hash,
  ja3n:       .tls.ja3,          # normalized pre-image
  ja4:        .tls.ja4,
  akamai_h2:  .http2.akamai_fingerprint_hash,
  ja4h:       .http1.ja4h // .http2.ja4h,
  peetprint:  .tls.peetprint_hash
}'

So the consequence is blunt. You cannot fix a TLS mismatch from JavaScript, ever. A userland Object.defineProperty on navigator runs after the handshake. A CDP Runtime.evaluate runs after the handshake. The ClientHello is already gone.

The classic tell: a Chrome UA over a Python handshake

This is the failure mode that spawned an entire tooling ecosystem.

A bare HTTP client that says it’s Chrome but hands over a Python TLS fingerprint is detected before it finishes the handshake.

as the common refrain goes in r/webscraping

The User-Agent header claims Chrome but the JA3/JA4 says OpenSSL-via-Python. The two disagree, and the disagreement is free for the server to spot. No challenge page, no JS, just a bucket assignment during the handshake.

# Same User-Agent header. Different client at the byte level.

# (a) requests - advertises Chrome, hands over an OpenSSL/urllib3 handshake
import requests
requests.get("https://tls.peet.ws/api/all",
             headers={"User-Agent": "Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36"})
#   JA4:        t13d1516h2_8daaf6152771_b186095e22b6   # OpenSSL cipher/ext set
#   Akamai H2:  1:65536;...|...|m,s,a,p                # urllib3 frame order
#   verdict:    UA says Chrome, TLS says Python  -> mismatch

# (b) curl_cffi - impersonates BoringSSL's ClientHello + Chrome's H2 frames
from curl_cffi import requests as cffi
cffi.get("https://tls.peet.ws/api/all", impersonate="chrome131")
#   JA4:        t13d1517h2_8daaf6152771_02713d6af862   # real Chrome cipher/ext set
#   Akamai H2:  1:65536;2:0;4:6291456;6:262144|...|m,a,s,p   # Chrome frame order
#   verdict:    UA and TLS agree

# (c) a real Chromium build (utls / BoringSSL underneath) - the fingerprint IS Chrome's,
#     because the bytes were generated by Chrome's actual TLS stack, not a mimic.

Libraries like curl_cffi, utls and BoringSSL-mimicking wrappers exist precisely to forge a Chrome-shaped ClientHello and HTTP/2 fingerprint from a language runtime that would otherwise betray itself. They patch the layer JavaScript can’t touch.

Where a real-Chromium build is an asset and where it breaks

There’s a structural point here for JS-layer anti-detect kernels, and it cuts both ways.

A kernel that spoofs JS fingerprints in C++ (canvas, WebGL, audio, fonts, navigator) but does not modify BoringSSL’s ClientHello or Chromium’s HTTP/2 frame construction emits a genuine Chrome TLS/JA4/HTTP2 fingerprint. Not a mimic. The real thing, byte-for-byte, because the bytes came out of Chrome’s actual network stack. Against a JS-only detector that’s perfect. Against a vendor that classifies at the handshake, the connection reads as real Chrome at the transport layer no matter what the JS says. The asset is real: the mimicking libraries spend enormous effort to get what a real build gives you for free.

The honest caveat is that the asset only survives if nothing rewrites it on the way out. Two things break it:

  • A proxy chain. Many rotating-residential and datacenter proxies terminate TLS or downgrade HTTP/2 to HTTP/1.1, re-emitting the request with the proxy’s handshake and header order. Now the JA4 is the proxy’s, not Chrome’s, and it no longer matches the UA.
  • An upstream library or SDK that reorders headers or rewrites SETTINGS frames between the browser and the wire.

So the verification rule: verify through the proxy. Route the actual egress path to tls.peet.ws/api/all and confirm JA3/JA4 and JA4H survived the trip. A build whose native handshake is perfect Chrome is worthless if the proxy in front of it re-terminates the connection. This is the one network-layer check that belongs in every proxy-rotation regression, right next to the timezone-follows-egress-IP check.

The vendor ladder is network-layer-driven

The difficulty of the hardest anti-bot stacks is disproportionately about signals gathered below JavaScript. From the 2026-07 vendor-stack survey:

Vendor Difficulty Network-layer role
Kasada Highest Client-side crypto PoW (x-kpsdk-cd), bare 429 with no body/challenge page, tokens rotate per-minute
Akamai Bot Manager Highest tier Stateful _abck cookie + sensor_data; TLS/JA-class classification at the edge before the sensor even posts
DataDome Very hard Builds a profile from the first request; moved to JA4 in early 2026; per-customer ML
Cloudflare (Enterprise BM) Hard JA3/JA4 + HTTP/2 fingerprint + bot score, evaluated at the edge
Imperva / Incapsula Hard Added JA4 signals in 2024; reese84 server-signed token

DataDome’s early-2026 shift to JA4 and Imperva’s 2024 addition of JA4 signals show the industry moving its primary classifier below the JS runtime, where a spoof can’t follow. The quantitative picture from the University of Bamberg dataset (Tranco Top 1M, ARES 2026) makes it stark. 75% of Chromium-headless blocks fire on HTTP-header-layer signals alone. Three-quarters of the wall sits below JavaScript. Their measured block rates were provider-driven, with Cloudflare (37%) and Akamai (26%) highest.

That reframes the whole exercise. A kernel can reach lieCount=0 on CreepJS, a genuine achievement against JS-layer consistency detection, and still get bucketed at the handshake by DataDome or Akamai before a single script runs. The two problems are orthogonal. JS-fingerprint consistency and network-layer fingerprinting get solved (or not) independently, and a scanner that only reports the former is telling you half the story.

Verifying your own stack

The tooling is small and the workflow is mechanical:

  • tls.peet.ws (Peet’s TrackMe) is the reference endpoint. /api/all returns the full ClientHello, JA3/JA3N/JA4, Akamai HTTP/2 and JA4H; /api/clean and /api/tls are trimmed variants. This is where curl_cffi and utls users check their work.
  • BrowserLeaks TLS gives human-readable JA3/JA4 for a quick eyeball.
  • Scrapfly JA3/JA4 tool cross-checks whether your TLS matches a real browser.
  • ja3.zone is a JA3 fingerprint library and UI.

Run all four through your production proxy, not on a direct connection. A direct-connection JA4 that matches Chrome tells you nothing about what your egress path actually emits.

Takeaway

The network layer is decided before your JavaScript exists, so JavaScript can’t repair it. That’s the whole game. Run a real Chromium build and its TLS and HTTP/2 fingerprints are genuinely Chrome’s, an asset a Python or Go scraper has to forge with curl_cffi or utls. But that asset is fragile. A proxy that re-terminates TLS, or a library that reorders headers, turns your real-Chrome JA4 into something that no longer matches your User-Agent, the exact mismatch that gets caught before the handshake finishes. Verify JA3/JA4/JA4H through the proxy, treat network-layer and JS-layer fingerprinting as two separate walls, and remember that on the hardest stacks most of the wall is the one your scripts never see. (2026-07 snapshot; vendors move their classifiers, so re-check.)

Sources & further reading
  1. tls.peet.ws (TrackMe), /api/all
  2. BrowserLeaks, TLS
  3. Scrapfly, JA3/JA4 fingerprint tool
More field notes
← Back to field notes