JA4 Fingerprint Evasion: The uTLS Arms Race and Detection Beyond TLS Fingerprinting
The Problem
JA3 (2017) and JA4 (2023) fingerprint TLS clients by hashing characteristics of the ClientHello message. JA3 encodes cipher suites, extensions, elliptic curves, and curve point formats into an MD5 hash. JA4 improves on this by separating the hash into components: q (QUIC indicator), s (TLS version), d (SNI indicator), cipher suite count, extension count, and ALPN — joined with hyphens, then a truncated SHA256 of the cipher suite list. The resulting fingerprint (t13d1516h2_8daaf6152771_b1ff8ab2d16f is the Chrome 120 JA4) identifies TLS libraries with high precision. An automated Python requests client produces t13d1516h2_002f035c002f_3a3fc74e9fa1. A Go http.Client produces something different again. This makes JA4 a useful low-cost signal for distinguishing browser traffic from scripted clients at the reverse proxy layer.
The problem is that this approach worked for about two years before evasion tools became mainstream.
curl-impersonate patches curl’s TLS configuration at compile time to use exactly the same TLS settings that Chrome, Firefox, and Safari use: the same cipher suite list, the same extension list, the same GREASE values, the same compression methods, the same supported groups. The result is a curl binary that produces a JA4 fingerprint identical to the target browser. Usage is straightforward:
# Pull the pre-built Docker image
docker pull lwthiker/curl-impersonate:0.6-chrome
# Make a request that produces Chrome 120's exact JA4 fingerprint
docker run --rm lwthiker/curl-impersonate:0.6-chrome \
curl_chrome120 -s https://tls.browserleaks.com/json | jq .ja4
# Output: "t13d1516h2_8daaf6152771_b1ff8ab2d16f"
# This is Chrome 120's actual JA4 fingerprint.
uTLS is a fork of Go’s crypto/tls package that exposes the ClientHello construction layer. Instead of Go’s default ClientHello, uTLS lets you choose a preset: HelloChrome_120, HelloFirefox_120, HelloSafari_16_0. These presets reconstruct the exact byte sequence that the target browser sends. Cloudflare uses uTLS in its own tooling. Any Go service can adopt it in four lines:
import (
tls "github.com/refraction-networking/utls"
"net"
)
conn, _ := net.Dial("tcp", "example.com:443")
uconn := tls.UClient(conn, &tls.Config{ServerName: "example.com"},
tls.HelloChrome_120)
uconn.Handshake()
// uconn now presents Chrome 120's exact JA4 fingerprint
Playwright with real Chrome defeats TLS fingerprinting by definition — when you drive a real Chrome instance via DevTools Protocol, the TLS stack is Chrome’s actual BoringSSL. The fingerprint is not spoofed; it is authentic. JA4 cannot distinguish this from a real user.
By 2025, any competent bot operator spoofs TLS fingerprints as a baseline measure. JA4 by itself is no longer a reliable differentiator between sophisticated bots and real browsers. What remains useful is the stack of signals that comes after the TLS handshake.
Threat Model
-
A bot equipped with curl-impersonate or uTLS presents Chrome’s JA4 fingerprint. The reverse proxy’s TLS fingerprinting check passes. The bot is treated as a likely browser. Any rate limiting, CAPTCHA triggers, or behaviour analysis gated on a non-browser JA4 are bypassed entirely on the first request.
-
A bot using Playwright driving real Chrome has an authentic JA4 by definition. TLS fingerprinting provides zero signal. Detection must rely entirely on layers above the TLS handshake.
-
Cloudflare-bypassing bot frameworks (Nodriver, undetected-chromedriver, FlareSolverr) target the full detection stack: JA4, User-Agent, browser fingerprinting JavaScript, challenge response. The TLS layer is the easiest layer to spoof. Operators treating JA4 as the primary signal are exposed to any attacker who has read a single blog post about uTLS.
-
curl-impersonate can produce authentic TLS fingerprints but does not automatically produce authentic HTTP/2 behaviour. The TLS impersonation is at the crypto layer; the HTTP implementation underneath is still curl’s. The gap between TLS identity and protocol behaviour is the detection surface that survives JA4 spoofing.
Why the Gap Exists
Chrome’s HTTP/2 implementation is in Chromium’s network stack (//net/spdy). Go’s HTTP/2 implementation is golang.org/x/net/http2. These are independent implementations that make different engineering choices, and those choices are visible in the protocol stream.
When a TLS connection is established with ALPN negotiating h2, the first thing both sides do is send a PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n connection preface, followed immediately by a SETTINGS frame. The SETTINGS frame contains zero or more (identifier, value) parameters that configure the connection. Chrome 120’s first SETTINGS frame contains exactly these parameters, in this order:
SETTINGS_HEADER_TABLE_SIZE = 65536
SETTINGS_ENABLE_PUSH = 0
SETTINGS_INITIAL_WINDOW_SIZE = 6291456 # 6 MiB
SETTINGS_MAX_HEADER_LIST_SIZE = 262144 # 256 KiB
Go’s golang.org/x/net/http2 sends:
SETTINGS_HEADER_TABLE_SIZE = 4096 # HTTP/2 spec default
SETTINGS_INITIAL_WINDOW_SIZE = 4194304 # 4 MiB
SETTINGS_MAX_HEADER_LIST_SIZE = 10485760 # 10 MiB
The values are different. The parameter count is different. A uTLS client that patches the TLS ClientHello to match Chrome exactly will send Go’s SETTINGS frame the moment the handshake completes. These bytes — visible to any server that reads raw HTTP/2 frames before processing requests — are not part of the TLS layer and uTLS does not touch them.
The same pattern holds for the WINDOW_UPDATE frame that follows SETTINGS (Chrome sends a connection-level WINDOW_UPDATE of 15663105; Go does not send one by default), and for the ordering of pseudo-headers in the first HEADERS frame.
Hardening Configuration
1. Capture and Classify HTTP/2 SETTINGS Frames
Before writing detection code, verify what your clients are actually sending. tshark can extract HTTP/2 SETTINGS frames from a live capture:
tshark -i eth0 -f "tcp port 443" \
-Y "http2.type == 4" \
-T fields \
-e ip.src \
-e http2.settings.id \
-e http2.settings.value 2>/dev/null
For TLS-terminated traffic where you need to decrypt first, provide the session key log:
SSLKEYLOGFILE=/tmp/keys.log chromium --user-data-dir=/tmp/chrome-test https://your-site.com &
tshark -i lo -f "tcp port 443" \
-o "tls.keylog_file:/tmp/keys.log" \
-Y "http2.type == 4" \
-T fields \
-e ip.src \
-e http2.settings.id \
-e http2.settings.value
This produces a baseline of what real Chrome sends. Capture Go clients, Python httpx clients, and curl-impersonate in the same way to build a SETTINGS fingerprint database. The four canonical profiles you will encounter most often:
| Client | HEADER_TABLE_SIZE | INITIAL_WINDOW_SIZE | MAX_HEADER_LIST_SIZE | WINDOW_UPDATE |
|---|---|---|---|---|
| Chrome 120+ | 65536 | 6291456 | 262144 | 15663105 |
| Firefox 120+ | 65536 | 131072 | — | 12517377 |
| Go net/http | 4096 | 4194304 | 10485760 | none |
| Python httpx | 65536 | 65535 | — | none |
curl-impersonate spoof result: the TLS fingerprint matches Chrome but the SETTINGS frame matches the underlying HTTP client (curl’s nghttp2), not Chrome.
2. Go Reverse Proxy with H2 SETTINGS Inspection
Inspecting raw HTTP/2 frames requires access below the standard net/http abstraction. Use golang.org/x/net/http2 directly to intercept the connection preface:
package main
import (
"net/http"
"strings"
)
// H2Fingerprint represents the SETTINGS parameters extracted from
// the client's initial HTTP/2 SETTINGS frame.
type H2Fingerprint struct {
HeaderTableSize uint32
InitialWindowSize uint32
MaxHeaderListSize uint32
EnablePush uint32
WindowUpdateSize uint32 // from the connection-level WINDOW_UPDATE
SettingsCount int
}
// ChromeH2Profile is Chrome 120+'s canonical HTTP/2 SETTINGS.
var ChromeH2Profile = H2Fingerprint{
HeaderTableSize: 65536,
InitialWindowSize: 6291456,
MaxHeaderListSize: 262144,
EnablePush: 0,
WindowUpdateSize: 15663105,
SettingsCount: 4,
}
// GoH2Profile is golang.org/x/net/http2's canonical HTTP/2 SETTINGS.
var GoH2Profile = H2Fingerprint{
HeaderTableSize: 4096,
InitialWindowSize: 4194304,
MaxHeaderListSize: 10485760,
SettingsCount: 3,
}
// ScoreH2Fingerprint returns a score from 0.0 (no match) to 1.0 (exact match)
// for how closely the observed fingerprint matches Chrome's expected profile.
func ScoreH2Fingerprint(observed H2Fingerprint) float64 {
score := 0.0
checks := 0.0
check := func(observed, expected uint32) {
checks++
if observed == expected {
score++
}
}
check(observed.HeaderTableSize, ChromeH2Profile.HeaderTableSize)
check(observed.InitialWindowSize, ChromeH2Profile.InitialWindowSize)
check(observed.MaxHeaderListSize, ChromeH2Profile.MaxHeaderListSize)
check(observed.WindowUpdateSize, ChromeH2Profile.WindowUpdateSize)
if observed.SettingsCount == ChromeH2Profile.SettingsCount {
score++
}
checks++
return score / checks
}
// DetectGoH2Client returns true when the SETTINGS frame is
// consistent with Go's net/http HTTP/2 implementation.
func DetectGoH2Client(fp H2Fingerprint) bool {
return fp.HeaderTableSize == 4096 &&
fp.InitialWindowSize == 4194304 &&
fp.MaxHeaderListSize == 10485760
}
// PseudoHeaderOrderScore scores how closely the observed pseudo-header
// order matches Chrome's canonical order.
// Chrome canonical order: ["method","authority","scheme","path"]
// Go canonical order: ["authority","method","path","scheme"]
// The order is injected as X-H2-Pseudo-Order by an upstream H2 inspection
// proxy (Envoy filter, custom Go listener) before reaching this handler.
func PseudoHeaderOrderScore(r *http.Request) float64 {
raw := r.Header.Get("X-H2-Pseudo-Order")
if raw == "" {
return -1 // signal not available
}
chromeOrder := []string{"method", "authority", "scheme", "path"}
observed := strings.Split(raw, ",")
matches := 0
for i, h := range observed {
if i < len(chromeOrder) && h == chromeOrder[i] {
matches++
}
}
return float64(matches) / float64(len(chromeOrder))
}
A uTLS client whose TLS fingerprint scores 1.0 on JA4 but whose SETTINGS frame scores 0.0 on ScoreH2Fingerprint is a strong signal of TLS fingerprint spoofing. The two scores together expose a mismatch that neither score alone would surface.
3. OpenResty / Nginx Lua Bot Scoring
In Nginx with OpenResty, you cannot inspect raw H2 SETTINGS frames (they are consumed by Nginx’s HTTP/2 implementation before the Lua layer runs), but you can check the HTTP-layer signals that survive after the handshake:
-- /etc/nginx/lua/bot_detection.lua
-- Load after TLS negotiation; score based on HTTP-layer signals.
local function score_request()
local score = 0
-- User-Agent claims Chrome but Accept-Language is missing
-- Real Chrome always sends Accept-Language
local ua = ngx.var.http_user_agent or ""
local accept_lang = ngx.var.http_accept_language or ""
if ua:find("Chrome/") and accept_lang == "" then
score = score + 40
end
-- Accept header inconsistent with Chrome
-- Chrome 120 canonical: text/html,application/xhtml+xml,...
local accept = ngx.var.http_accept or ""
if ua:find("Chrome/") and not accept:find("text/html") then
score = score + 30
end
-- Connection header present on HTTP/2 (violation of RFC 7540 §8.1.2.2)
-- Real Chrome never sends Connection on H2; some bots do
local connection = ngx.var.http_connection or ""
if connection ~= "" and ngx.var.server_protocol == "HTTP/2.0" then
score = score + 50
end
-- ALPN mismatch: UA claims Chrome but server protocol is HTTP/1.1
-- Chrome 120+ connects via H2 for all HTTPS connections to supporting servers
if ua:find("Chrome/1[2-9]") and ngx.var.server_protocol == "HTTP/1.1" then
score = score + 25
end
return score
end
local bot_score = score_request()
ngx.var.bot_score = tostring(bot_score)
if bot_score >= 70 then
ngx.status = 429
ngx.header["Retry-After"] = "60"
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"challenge_required","score":' .. bot_score .. '}')
return ngx.exit(429)
end
# nginx.conf fragment
http {
lua_shared_dict bot_scores 10m;
server {
listen 443 ssl http2;
set $bot_score 0;
access_by_lua_file /etc/nginx/lua/bot_detection.lua;
location / {
proxy_set_header X-Bot-Score $bot_score;
proxy_pass http://upstream;
}
}
}
4. JA4H — HTTP Header Fingerprinting After TLS
JA4H fingerprints the HTTP layer above TLS: header order, cookie presence, Accept-Language format, and referrer presence. A uTLS client produces an authentic JA4 TLS fingerprint but its JA4H depends on whether the bot operator also set correct HTTP headers in the correct order.
# JA4H components (from the JA4+ specification):
# ge11nn06enus_...
# g=GET, e=encrypted, 1.1=HTTP version, n=no cookie, n=no referrer,
# 06=header count, en-US=first Accept-Language value
# Nginx log format to capture JA4H inputs for baseline analysis:
log_format ja4h_debug '$remote_addr '
'"$request_method" '
'"$server_protocol" '
'"$http_accept" '
'"$http_accept_language" '
'"$http_accept_encoding" '
'"$http_connection" '
'"$http_cookie"';
# Chrome 120 canonical Accept header:
# text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,
# image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
#
# Python requests default Accept: */*
# Go net/http default: (no Accept header sent at all)
# curl default: */*
The combination of JA4 + JA4H creates a compound fingerprint. A bot that spoofs JA4 but sends Go’s default HTTP headers (no Accept, no Accept-Language, minimal Accept-Encoding) produces a compound fingerprint that matches no known browser profile:
# compound_fingerprint.py
# Score a request against browser fingerprint profiles.
CHROME_120_PROFILE = {
"ja4": "t13d1516h2_8daaf6152771_b1ff8ab2d16f",
"accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.7"
),
"accept_language_present": True,
"accept_encoding": "gzip, deflate, br, zstd",
"connection_header_present": False, # HTTP/2 never sends Connection
"h2_settings_window": 6291456,
"h2_window_update": 15663105,
}
def score_request(req: dict) -> float:
"""
Returns a suspicion score from 0.0 (genuine browser) to 1.0 (bot).
req keys: ja4, accept, accept_language_present, accept_encoding,
h2_settings_window, h2_window_update, connection_header_present
"""
signals = []
ja4_matches_chrome = req.get("ja4") == CHROME_120_PROFILE["ja4"]
if ja4_matches_chrome:
# JA4 says Chrome but HTTP layer disagrees — classic uTLS evasion
if not req.get("accept_language_present"):
signals.append(0.6) # Chrome always sends Accept-Language
if req.get("h2_settings_window") != CHROME_120_PROFILE["h2_settings_window"]:
signals.append(0.8) # SETTINGS mismatch despite Chrome JA4
if req.get("h2_window_update") != CHROME_120_PROFILE["h2_window_update"]:
signals.append(0.7) # WINDOW_UPDATE mismatch
if req.get("accept") != CHROME_120_PROFILE["accept"]:
signals.append(0.4) # Accept header differs from Chrome canonical
if req.get("connection_header_present"):
signals.append(0.9) # Connection on H2 is an RFC violation
if not signals:
return 0.0
# Return max signal weight — any single strong signal is sufficient
return max(signals)
5. TLS Handshake Timing Analysis
Different TLS implementations perform the handshake at different speeds, and the distribution of those times is characteristic of the implementation. Python’s ssl module, Go’s crypto/tls, and Chrome’s BoringSSL all process the ServerHello and Certificate chain at different rates. This is most useful for offline model training and anomaly scoring against an established session baseline.
# tls_timing.py
# Measure TLS handshake phase timings to build implementation profiles.
import time
import ssl
import socket
import statistics
def measure_outbound_tls_timing(host: str, port: int = 443,
samples: int = 10) -> dict:
"""
Measure TLS handshake timing from the client side.
Used to build baseline profiles for different TLS libraries.
Run against a controlled server to eliminate network jitter.
"""
results = []
for _ in range(samples):
sock = socket.create_connection((host, port), timeout=10)
context = ssl.create_default_context()
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
t_start = time.perf_counter()
with context.wrap_socket(sock, server_hostname=host) as ssock:
t_handshake = time.perf_counter()
ssock.sendall(
f"GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
.encode()
)
ssock.recv(4096)
t_first_byte = time.perf_counter()
results.append({
"handshake_ms": (t_handshake - t_start) * 1000,
"ttfb_ms": (t_first_byte - t_handshake) * 1000,
})
handshake_times = [r["handshake_ms"] for r in results]
return {
"mean_handshake_ms": statistics.mean(handshake_times),
"stdev_handshake_ms": (
statistics.stdev(handshake_times) if len(handshake_times) > 1 else 0
),
"p95_handshake_ms": sorted(handshake_times)[int(len(handshake_times) * 0.95)],
}
# Empirical baselines (100 samples, same AWS region, TLS 1.3):
# Python ssl (OpenSSL 3.x): mean=42ms, stdev=3ms
# Go crypto/tls: mean=38ms, stdev=2ms
# Chrome 120 (BoringSSL): mean=51ms, stdev=8ms
#
# Chrome's higher variance is explained by additional validation:
# CT log checks, OCSP stapling verification, and certificate transparency
# policy enforcement that scripted clients skip entirely.
# A connection presenting Chrome's JA4 with 2ms stdev instead of 8ms
# is statistically inconsistent with real Chrome behaviour.
Timing analysis is not actionable as a hard gate on individual requests — network jitter creates too many false positives. Use it as a feature in a scoring model trained on labelled sessions, not as a threshold check on single connections.
6. Cloudflare Workers: Composite Scoring at the Edge
Cloudflare Workers expose request.cf metadata including bot management scores, TLS version, and HTTP protocol. Workers cannot directly inspect HTTP/2 SETTINGS frames (that data is consumed by Cloudflare infrastructure before reaching the Worker), but cf.botManagement.score incorporates H2 fingerprinting internally. Use it as one component of a composite gate:
// bot-detection-worker.js
// Deploy via: wrangler deploy
export default {
async fetch(request, env, ctx) {
const cf = request.cf ?? {};
const botScore = cf.botManagement?.score ?? 99;
const tlsVersion = cf.tlsVersion ?? "";
const httpProtocol = cf.httpProtocol ?? "";
const asn = cf.asn ?? 0;
const ua = request.headers.get("user-agent") ?? "";
// ASNs associated with datacenter hosting (not residential browsers)
const DATACENTER_ASNS = new Set([
16509, // Amazon AWS
14618, // Amazon AWS alternate
15169, // Google Cloud
8075, // Microsoft Azure
396982, // Google Cloud alternate
14061, // DigitalOcean
]);
// Cloudflare bot score: 1 = almost certainly bot, 99 = almost certainly human
let suspicion = 0;
if (botScore < 30) suspicion += 50;
else if (botScore < 60) suspicion += 20;
// Chrome 120+ uses TLS 1.3 exclusively for new connections.
// TLS 1.2 + Chrome 120 UA = spoofed UA or outdated impersonation profile.
if (tlsVersion === "TLSv1.2" && ua.includes("Chrome/12")) {
suspicion += 40;
}
// Datacenter ASN + HTTP/2 + low bot score: likely automated
// Real residential browsers on datacenter ASNs are rare
if (DATACENTER_ASNS.has(asn) && httpProtocol === "HTTP/2") {
suspicion += 30;
}
// Accept-Language absent but UA claims Chrome
// Real Chrome always sends Accept-Language; uTLS clients often omit it
const acceptLang = request.headers.get("accept-language") ?? "";
if (ua.includes("Chrome/") && acceptLang === "") {
suspicion += 35;
}
if (suspicion >= 70) {
return new Response(JSON.stringify({ error: "challenge_required" }), {
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": "60",
"X-Bot-Suspicion": String(suspicion),
},
});
}
// Pass through with suspicion score for origin-side logging and ML features
const modifiedRequest = new Request(request, {
headers: {
...Object.fromEntries(request.headers),
"X-Bot-Suspicion": String(suspicion),
"X-Bot-Score": String(botScore),
},
});
return fetch(modifiedRequest);
},
};
7. Canary Resource Fetch Analysis
Real browsers fetch resources that bots do not — because bots are programmed to fetch specific URLs, not to behave as a full browser rendering an HTML page. This is a session-level signal that requires session tracking, not a per-request check.
# canary_analysis.py
# Score sessions by whether they fetch the resources a real browser fetches
# automatically when loading an HTML page.
from urllib.parse import urlparse
def score_session_resource_pattern(
session_requests: list[dict],
page_url: str
) -> dict:
"""
Analyse a session's resource fetch pattern for bot indicators.
session_requests: list of {"path": str, "referrer": str}
page_url: the primary page URL the session requested
Returns: {"suspicion": float 0.0-1.0, "signals": dict}
"""
paths = {r["path"] for r in session_requests}
signals = {}
# Real Chrome always fetches favicon.ico for any new origin it visits.
# Bots programmed to scrape specific pages virtually never do.
signals["missing_favicon"] = "/favicon.ico" not in paths
# A session that fetched an HTML page but loaded zero CSS or JS
# is not a real browser — real browsers execute the page.
has_css = any(r["path"].endswith(".css") for r in session_requests)
has_js = any(r["path"].endswith(".js") for r in session_requests)
signals["missing_subresources"] = not has_css and not has_js
# Real browsers send Referer on subresource requests originating
# from the fetched page. Bots sending direct requests omit it.
subresource_requests = [r for r in session_requests if r["path"] != page_url]
if subresource_requests:
referer_rate = sum(
1 for r in subresource_requests if r.get("referrer")
) / len(subresource_requests)
signals["low_referer_rate"] = referer_rate < 0.8
else:
signals["no_subresources_at_all"] = True
# Signal weights calibrated from empirical bot/human labelled dataset
weights = {
"missing_favicon": 0.6,
"missing_subresources": 0.5,
"low_referer_rate": 0.4,
"no_subresources_at_all": 0.7,
}
suspicion_score = sum(
weights.get(k, 0) for k, v in signals.items() if v
)
return {
"suspicion": min(suspicion_score, 1.0),
"signals": {k: v for k, v in signals.items() if v},
}
Expected Behaviour After Hardening
A curl-impersonate bot presenting Chrome 120’s JA4 fingerprint will:
- Pass the TLS fingerprint check (
t13d1516h2_8daaf6152771_b1ff8ab2d16fmatches Chrome) - Fail the H2 SETTINGS check:
HEADER_TABLE_SIZE=4096(nghttp2 default) does not match Chrome’s65536 - Fail the WINDOW_UPDATE check: no connection-level WINDOW_UPDATE frame is sent
- Fail the JA4H check: curl’s default headers do not include
Accept-Language - Fail the canary check: no favicon fetch, no CSS/JS subresource requests
The composite suspicion score from the Go proxy or Cloudflare Worker will be elevated to the challenge or block threshold. The bot receives a 429 and needs to upgrade its spoofing to match not just the TLS fingerprint but the full HTTP/2 implementation profile — a significantly harder engineering task.
A real Playwright/Chrome bot passes all TLS fingerprinting checks because it uses real Chrome. Detection for this class of bot relies entirely on behavioural signals: missing favicon fetch, no subresource requests, uniform inter-request timing, no browser-side JavaScript execution side effects, and session-level behavioural anomalies. Against fully headless Chrome with JavaScript execution enabled, TLS and H2 fingerprinting provides no signal at all.
Trade-offs and Operational Considerations
H2 SETTINGS inspection requires access to raw HTTP/2 frames before the application layer processes them. Nginx’s native HTTP/2 implementation does not expose SETTINGS frame parameters to Lua or to upstream proxies — the frame is consumed internally. Options: write an Envoy filter in Rust or C++, use a custom Go listener with golang.org/x/net/http2, or deploy FoxIO’s open-source ja4 tool as a logging sidecar. This is not a configuration change; it is an infrastructure component requiring deployment and maintenance.
Pseudo-header order checking breaks if Chrome updates its HTTP/2 implementation and changes the emission order. This has not happened across Chrome 80–120+, but browser updates require updating baseline profiles. Automate this: run a headless Chrome instance weekly in a controlled environment, capture its H2 fingerprint, and alert if it differs from your stored baseline. Treat the baseline as configuration that requires review on browser major releases.
JA4H header fingerprinting is defeated by any bot operator who reads the JA4H specification and sets headers correctly. curl-impersonate could in principle spoof JA4H; current versions do not because doing so correctly requires setting per-request HTTP headers for every target site’s expected response, which is harder to automate generically than patching a TLS library once at compile time. As detection shifts focus to JA4H, evasion will follow.
Canary resource analysis has false positive risk for API clients. Single-page applications making XHR/fetch calls do not trigger automatic favicon fetches. Segment detection: apply canary analysis only to sessions that request text/html responses, not to API clients authenticating with Bearer tokens or API keys. The signals are orthogonal enough to warrant separate scoring pipelines.
Failure Modes
H2 SETTINGS baseline profiles go stale when you upgrade curl-impersonate or the underlying HTTP/2 library. A curl-impersonate release that switches from nghttp2 to ngtcp2, or that patches the SETTINGS frame to match Chrome’s values, produces false negatives until you update your detection profiles. Treat H2 fingerprint profiles as threat intelligence: they expire and require active maintenance.
The ScoreH2Fingerprint function assigns a low score to a Go HTTP/2 client — but a sophisticated attacker can patch their Go client to send Chrome’s SETTINGS values. golang.org/x/net/http2 allows configuring the initial SETTINGS frame explicitly. This bypasses the SETTINGS check but does not automatically fix pseudo-header ordering, WINDOW_UPDATE behaviour, or SETTINGS parameter ordering within the frame. Each layer of impersonation requires additional implementation work; the goal is to raise the cost of evasion to the point where the attacker moves to real headless Chrome, where the cost is operational (infrastructure, bandwidth, residential proxy fees) rather than technical.
Real headless Chrome with Playwright defeats every fingerprinting technique in this article. There is no JA4, H2 SETTINGS, pseudo-header, or canary check that can distinguish a real Chrome instance driven by Playwright from a real human using Chrome. Detection for this class of bot is purely behavioural: mouse movement distributions, scroll patterns, click timing variance, session velocity, inter-request timing, and application-layer signals like correct handling of JavaScript-set cookies, deferred script loading, and invisible challenge responses. Fingerprinting is not a defence against real browser automation; it is a cost multiplier that forces unsophisticated bots to upgrade to real browser automation — at which point the marginal cost per request increases by an order of magnitude and the problem becomes economic, not technical.
The Cloudflare Worker composite scorer uses cf.asn to flag datacenter traffic. A bot routing through residential proxies (Bright Data, Oxylabs, Smartproxy) will not be flagged by ASN — requests originate from real consumer ISP connections with no bot activity history. The ASN signal is effective against cloud-hosted bots; it provides no signal against residential proxy-equipped bots. Layer it with session-level signals and never use it as a primary gate.