Skip to content

Detecting curl-impersonate and uTLS: the second-order tells

· 23 min read
Copyright: MIT
The word impersonate in monospace with one orange glyph over a dark background

A tool like curl-impersonate can produce a ClientHello that is byte-for-byte indistinguishable from Chrome. Same cipher list, same extension order, same GREASE placement, same supported groups. Run it through JA3 or JA4 and you get the exact hash a real Chrome would produce. So the obvious question, the one that decides whether any of this matters: if the fingerprint is perfect, how does anyone catch it?

The answer is that the ClientHello is the one part of the stack these tools control completely, and detectors stopped trusting any single layer years ago. A request is a tall column of decisions: TCP options, the TLS handshake, the HTTP/2 frames that open the connection, the header order, the cookies, and eventually the JavaScript environment if the client runs one. Forging the bottom of that column perfectly does nothing for the layers above it, and the layers above leak a different identity. The tell is not a flaw in the forgery. The tell is the seam between a forged layer and an honest one.

This post walks that seam. It starts with what curl-impersonate and uTLS actually get right and why the TLS layer alone is a solved problem. Then it moves up the stack to the HTTP/2 layer, where the Akamai fingerprint lives, and shows why a library that nails the ClientHello usually still announces itself in its SETTINGS frame. It covers library-specific residue (the things BoringSSL-via-curl does that BoringSSL-via-Chrome does not), header order as an independent fingerprint, and the slow bleed of version drift that turns a perfect 2024 profile into a 2026 anomaly. Throughout, the focus is defensive: this is how the detection works, not a recipe for defeating it. Where the internals are not publicly documented, that is stated plainly.

What the forgery gets right

Start with the part that works, because the rest of the post only makes sense if you accept that the TLS layer is genuinely solved.

Most HTTP clients are trivially distinguishable from browsers at the TLS layer. The ClientHello a default libcurl sends, built on OpenSSL, carries a different cipher list, different extensions, and a different ordering than any browser ships. Daniel Stenberg, curl’s author, put the mechanism plainly: the fingerprint is a function of the curl version, the TLS library, the library’s version, and the options curl sets. Change any of those and the fingerprint moves. A browser is just a different point in that same space, and for years that difference is what got scrapers blocked on the first packet.

curl-impersonate closes the gap by changing the inputs to that function. For Chrome targets it rebuilds curl against BoringSSL, Google’s TLS library, instead of OpenSSL. For Firefox targets it builds against NSS, the library Firefox actually uses. Then it patches curl to set the extensions and options the way the target browser does. The project’s own write-up lists the specific additions: enabling GREASE with a single SSL_CTX_set_grease_enabled call, turning on Brotli certificate compression via SSL_CTX_add_cert_compression_alg, and adding ALPS, Google’s extension that carries application-layer settings inside the handshake, with SSL_add_application_settings. Each of these is an extension a real Chrome sends and a default curl does not.

uTLS does the same thing one layer of abstraction higher, in Go. The standard library’s crypto/tls produces a ClientHello you cannot fully control; the field order and extension set are baked into the implementation. uTLS replaces that handshake with one you build from a parroted spec, so a Go program can emit Chrome’s exact ClientHello rather than Go’s. The mechanics are covered in the companion piece on how uTLS mimics browser ClientHellos. The relevant point here is that both tools are good at this. The TLS layer is not where they get caught.

If you want the field-level detail of what goes into that hash, the ClientHello field reference and the JA3-to-JA4 walkthrough cover it. For this post, assume the ClientHello is perfect and ask what is left.

One request, layer by layer claims: Chrome TLS ClientHello (JA3 / JA4) forged HTTP/2 SETTINGS + WINDOW_UPDATE (Akamai fp) often honest header order + Sec-CH-UA (JA4H) often honest JS runtime, behaviour, timing absent The detection is the disagreement between row 1 and the rows above it. *The forgery is total at the TLS layer and partial or absent everywhere above. Detection lives in the contradiction, not in any single layer.*

The HTTP/2 layer, where the forgery usually stops

Once the TLS handshake completes and ALPN selects h2, the client opens an HTTP/2 connection. Before it sends a single request, it sends a connection preface and a SETTINGS frame, and that SETTINGS frame is its own fingerprint. This is the layer most TLS-forging tools forget, and it is the one Akamai built a passive fingerprint around.

The format comes from an Akamai whitepaper presented at Black Hat EU in 2017 by Elad Shuster and colleagues. It concatenates four components into one pipe-separated string:

SETTINGS | WINDOW_UPDATE | PRIORITY | pseudo-header order

The SETTINGS section lists the parameters the client sends as id:value pairs, in the order sent, joined by semicolons. The values matter and so does which parameters appear at all. The WINDOW_UPDATE section is the increment from the connection-level WINDOW_UPDATE frame the client sends right after the preface, or 0 if it sends none. The PRIORITY section captures any PRIORITY frames, with stream id, exclusivity bit, dependency, and weight. The last section is the order of the HTTP/2 pseudo-headers (:method, :authority, :scheme, :path), which is implementation-specific and abbreviated to single letters.

A real Chrome produces something close to this:

1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p

That reads as: header table size 65536, push disabled, initial window size 6291456, max header list size 262144; a connection window increment of 15663105; no PRIORITY frames; and pseudo-headers in the order method, authority, scheme, path. Firefox produces a visibly different string. It selects a different set of SETTINGS parameters, sends a different window increment, has historically sent explicit PRIORITY frames where Chrome sends none, and orders its pseudo-headers m,p,a,s. The two browsers are distinguishable on this line alone, which is the whole point of the fingerprint.

Akamai HTTP/2 fingerprint, Chrome

1:65536;2:0;4:6291456;6:262144 | 15663105 |

0 | m,a,s,p

SETTINGS frame, id:value pairs in send order connection-level WINDOW_UPDATE increment PRIORITY frames (0 = none sent) pseudo-header order: method, authority, scheme, path The Shuster format. Each section is an independent decision the client’s HTTP/2 stack makes, and a TLS-forging library inherits its own stack’s choices unless it patches them too.

Here is why this catches tools. The TLS handshake and the HTTP/2 stack are different pieces of software. curl-impersonate swaps in BoringSSL for the handshake, but the HTTP/2 framing is curl’s own nghttp2-based implementation, and its defaults are not Chrome’s. The lwthiker write-up names the gaps directly: curl’s default SETTINGS_MAX_CONCURRENT_STREAMS was 100 against Chrome’s 1000, and Chrome sends an “unknown random setting” the author could not initially account for. The pseudo-header order differed too. None of that is visible in JA3 or JA4, because those only hash the ClientHello. A request can pass the TLS check cleanly and then fail the HTTP/2 check on the very next frame.

This is why curl-impersonate patches the HTTP/2 settings as well as the TLS options, and why a tool that patches only the TLS layer is so easy to spot. A Go program using uTLS for the handshake but the standard library’s net/http for HTTP/2 emits Go’s SETTINGS frame, not Chrome’s. A Python script doing the same with a hand-rolled handshake but hyper-h2 or httpx defaults above it emits that library’s framing. The TLS layer says Chrome; the HTTP/2 layer says Go, or Python, or nghttp2. Detectors that compute both fingerprints and compare them do not need to know which tool you used. They only need to notice that the two layers disagree.

Akamai has been explicit that this is the model. Its bot-detection writing describes layering an enhanced TLS fingerprint together with HTTP/2 signals from the SETTINGS frame and window values, precisely so that a client which forges one layer trips on the other. The deeper mechanics of how that vendor wires HTTP/2 into its scoring are in the DataDome HTTP/2 fingerprinting and Cloudflare TLS and HTTP/2 pieces; the principle is the same across vendors.

Header order and the JA4H line

Above HTTP/2 sits the request itself, and the order of its headers is a fingerprint too. Browsers emit request headers in a stable, implementation-defined order. HTTP libraries emit them in whatever order the programmer added them, or in the order their internal map happens to iterate, which is often alphabetical or insertion-based and rarely matches a browser.

FoxIO’s JA4H, part of the JA4+ suite released in 2023, captures this. The fingerprint packs the request into a compact form:

{method}{version}{cookie}{referer}{header-count}{lang}_{header-hash}_{cookie-hash}_{cookie-value-hash}

The leading segment is human-readable. ge11nn14enus reads as a GET over HTTP/1.1, no Cookie header, no Referer, 14 headers (not counting Cookie and Referer), with an Accept-Language of en-US. The three trailing segments are truncated SHA-256 hashes: one of the header names, one of the cookie names, one of the cookie names with values. The header-name hash is the load-bearing one for our purposes, because it is computed over the names in the order they appear in the request. Order changes the hash. A browser’s order produces one value; a library’s default order produces another. (Worth a caveat: some JA4H implementations sort the header names before hashing as a workaround for languages that store headers in an unordered map, which weakens the order signal in those specific implementations. The reference spec hashes in request order.)

The practical detection rarely needs the full hash. The cheaper version is a direct comparison against the known header order for the claimed browser. DataDome’s own anti-bypass guidance describes checking header completeness and order as a unit: Chrome sends a particular set of headers in a particular sequence, and a request that has the right TLS fingerprint but the wrong header order, or is missing Sec-Fetch-Dest, or sends a stale Sec-CH-UA brand list, contradicts its own claim. The cross-header checks are even cheaper. A request with sec-ch-ua-platform: "Windows" and a User-Agent string carrying Macintosh; Intel Mac OS X is an instant fail, because no real Chrome produces that pair. The GREASE brand inside Sec-CH-UA has to match the Chrome major version that the TLS fingerprint implies; a mismatch there is a version-drift signal of its own.

This is the same cross-layer logic as the HTTP/2 check, moved up one floor. The TLS layer claims a browser and a version. The headers carry their own claim about browser and version and platform. When a tool forges the ClientHello but lets its HTTP library assemble the headers, the two claims drift apart, and the gap is the signal.

real Chrome forged client

JA4: Chrome JA4: Chrome H2: Chrome SETTINGS H2: nghttp2 defaults JA4H: Chrome order JA4H: lib order UA: Windows / Win UA UA: Win CH / Mac UA

all four agree -> trusted row 1 disagrees with 2-4 -> flagged

A perfect ClientHello buys nothing if the rows above it tell a different story. *The detector does not score the ClientHello in isolation. It scores agreement across layers, and a single forged layer makes the disagreement louder, not quieter.*

Library-specific residue

Even when a tool patches the HTTP/2 settings and fixes the header order, it leaves residue. Residue is the set of small behaviours that come from the specific library doing the impersonating rather than from the browser it claims to be. Two stacks can produce the same fingerprint string and still behave differently in ways a careful detector can observe.

The clearest example is the “unknown random setting” the lwthiker author noticed in Chrome’s SETTINGS frame. A faithful impersonation has to reproduce whatever that parameter is, with whatever value Chrome uses, in the right position. Get the value wrong, or omit it, or place it in the wrong order, and the SETTINGS string no longer matches even though the impersonation tried. Chrome’s choices here are tied to its build and change across versions, so a static profile baked into a tool at one point in time can fall out of sync with the browser it copies.

Timing and framing residue is subtler. The Akamai fingerprint cares about the connection-level WINDOW_UPDATE and any PRIORITY frames, but a real browser also exhibits a characteristic rhythm: when it sends the WINDOW_UPDATE relative to the preface, how it sizes per-stream flow control, whether it coalesces requests onto one connection or opens several. A library reproduces the static fingerprint far more easily than it reproduces this dynamic behaviour, because the static fingerprint is a fixed string to copy and the behaviour is an emergent property of a different codebase. None of the public vendor documentation lays out exactly which dynamic signals are scored or how they are weighted; what is documented is that the SETTINGS, WINDOW_UPDATE, and PRIORITY frames feed the fingerprint, and what is reasonable to infer is that a stack which gets the string right can still get the timing wrong.

There is also residue at the TLS layer that survives a correct JA3 or JA4. The hashes deliberately ignore some fields. JA3 does not capture the contents of GREASE values (it filters them out, which is the entire reason GREASE breaks naive fingerprinting), and neither hash captures everything in the record layer or the exact bytes of every extension’s payload. A detector that goes beyond the published hash and inspects the raw ClientHello can find places where a forgery is structurally correct but not bitwise identical to the browser. Cipher suite order is in the hash, so a tool has to get cipher ordering exactly right, but the long tail of fields the hash skips is exactly where a library that is “close enough for JA4” stops being close enough for a raw-bytes comparison. This is the cipher-stunting problem in reverse: where attackers once randomized ciphers to escape JA3, defenders now look past JA3 at the fields it never covered.

A different flavour of residue lives below the TLS layer, at TCP and IP. The operating system that terminates the connection sets the initial window size, the TCP options and their order, the window scale factor, and the TTL on outbound packets, and those choices are an OS fingerprint independent of anything the application does. FoxIO published JA4T and JA4L for exactly this, capturing TCP options and a latency-derived distance estimate. The point for impersonation is the consistency check it enables. A User-Agent claiming Chrome on Windows, riding a TLS fingerprint for Chrome on Windows, arriving over a TCP stack whose options say Linux, is internally inconsistent. The tool runs on a Linux server; the OS it claims is Windows; and the TCP layer is the one part of the stack a userspace HTTP client cannot easily rewrite, because it belongs to the kernel. That makes the network-stack fingerprint a stubborn honest layer underneath a forged one. How much weight any given vendor puts on it is not documented, and a proxy or load balancer in the path can rewrite enough of it to muddy the signal, so treat this as a corroborating tell rather than a decisive one.

HTTP/3 moves the same problem onto QUIC

HTTP/3 does not erase any of this. It reimplements HTTP/2’s semantics over QUIC, and QUIC carries its own initial parameters: the transport parameters in the QUIC handshake, the order and presence of frames, the way the client paces and acknowledges. Those are as fingerprintable as the HTTP/2 SETTINGS frame, and arguably more so, because the QUIC stack is younger and the implementations diverge more. curl-cffi’s own documentation notes that HTTP/3 fingerprinting has not been as widely exploited in production as HTTP/2, and that some operators see lower detection over HTTP/3 today. That gap is a function of deployment maturity, not of any property that makes QUIC harder to fingerprint. As vendors finish wiring QUIC parameters into their scoring, a tool that forges the TLS-over-QUIC handshake but uses its library’s default QUIC transport parameters inherits exactly the cross-layer seam this whole post is about, one protocol over. The structural problem does not go away when the transport changes. It moves.

Version drift, the tell that arrives on its own

The most reliable signal against impersonation tools is the one that needs no cleverness at all. It just waits.

A browser fingerprint is a snapshot of one build. Chrome’s ClientHello, its SETTINGS frame, its Sec-CH-UA brand list, its supported groups: all of it moves as the browser ships new versions. When Chrome 124 added the X25519Kyber768 post-quantum key share (later X25519MLKEM768), every real Chrome on the planet started sending it within a release cycle or two, and a profile that predated it instantly looked like an old browser. The same happened with Brotli certificate compression, with ZSTD support in Chrome 123, with ECH support, with each change to the extension set. Chrome’s own extension order is randomized per-connection now, a change that broke JA3, so a tool that emits a fixed extension order is anomalous against a browser that shuffles.

Impersonation tools update on a different clock than browsers do. The original lwthiker curl-impersonate stalled; its last release, v0.6.1, shipped in March 2024 with profiles topping out around Chrome 116 and Firefox 117. The browser kept moving. By 2026 a request claiming Chrome via a 2024-era profile sits in a strange place: the User-Agent says a current version, the TLS fingerprint says a year-old one, and no real population of users looks like that. The active fork by lexiforest exists precisely because someone had to keep chasing the browser, and it carries profiles up through recent Chrome, Firefox, and Safari builds for exactly this reason. The fork’s existence is the clearest evidence that drift is the dominant failure mode. If a static profile aged gracefully, nobody would need to keep re-cutting it.

Fingerprint drift over time 2024 2026 real Chrome, every release pinned tool profile, frozen at cut date the growing gap is the anomaly A UA claiming the current version with a year-old TLS fingerprint matches no real user population. The mismatch needs no JS to detect. *Real browsers move their fingerprint on every release. A pinned profile holds still. The gap between the claimed version and the observed fingerprint widens on its own until it is unmistakable.*

Drift cuts the other way too, and this is the subtle part. To stay current a tool has to update its whole bundle in lockstep: TLS profile, HTTP/2 settings, header order, and Sec-CH-UA brand list all need to advance to the same version at the same time. Update the User-Agent string but not the supported groups and you have manufactured a mismatch by hand. The version a request claims has to be consistent across every layer simultaneously, which is a much harder invariant to hold than getting any single layer right once. A real browser holds it for free because all the layers come from one build. An impersonation stack assembles those layers from different sources and has to keep them aligned by discipline.

How a detector wires the consistency check

It helps to see the check from the defender’s side, because once you do, the reason a perfect ClientHello does not save the request becomes mechanical rather than abstract. A detector that sits at the edge sees every layer of the connection in one place: it terminates TLS, so it has the raw ClientHello; it speaks HTTP/2, so it has the SETTINGS frame and the window updates; it parses the request, so it has the headers in order. From those it computes a handful of fingerprints and then asks one question across them.

ja4 = fingerprint(client_hello) # -> a browser + version family
h2 = akamai_h2(settings, wu, prio, ps) # -> a browser + version family
ja4h = fingerprint(request_headers) # -> a browser + version family
claim = parse(user_agent, sec_ch_ua) # -> the browser + version asserted
if not all_agree(ja4, h2, ja4h, claim):
raise InconsistentClient

The interesting work is inside all_agree. It is not a string compare. Each fingerprint maps to a known set of browser-and-version families, maintained from real traffic and from running the browsers in a lab. The question is whether there exists a single browser build that would have produced all four observations at once. For a real Chrome, that build exists by construction, because all four came from it. For a tool that forged the ClientHello but let its HTTP library do the rest, no single build satisfies the system: the ClientHello points at Chrome 146, the SETTINGS frame points at nghttp2 or Go’s net/http, the header order points at the library’s defaults, and the User-Agent points wherever the operator typed. The detector does not have to know the tool. It only has to find that the intersection of the four candidate sets is empty.

This is why adding more fingerprints strictly helps the defender and strictly hurts the forger. Every layer the detector scores is one more constraint the forgery has to satisfy simultaneously. A tool that patches the ClientHello satisfies one constraint. A tool that also patches the HTTP/2 settings satisfies two. To satisfy all of them at once it has to reproduce a complete, coherent browser across every observable layer, at a specific version, with no piece lagging, which is most of the way to just running the browser. The cost curve bends against impersonation as the number of independent layers grows, and the JA4+ suite was designed around exactly that arithmetic. Cloudflare, Akamai, and DataDome each describe variations of the same multi-layer consistency model in their public writing on TLS and HTTP/2 scoring, and the server-side-versus-client-side breakdown covers where this check sits relative to the JavaScript probes that run later.

The defender’s check has a cost of its own, and it is worth naming honestly. Computing four fingerprints per connection and intersecting candidate sets is more expensive than hashing one ClientHello, and the candidate-set databases have to be kept current as browsers ship, or the consistency check starts producing false positives against real, freshly-updated browsers. That maintenance burden is the mirror image of the forger’s drift problem. Both sides are running to keep up with the browser. The asymmetry is that the defender only has to recognize the browser, while the forger has to reproduce it, and recognizing is cheaper than reproducing.

Where the public record ends

A note on certainty, because the brief for this kind of reference is to be honest about the edges. The structure of these fingerprints is public. The Shuster HTTP/2 format, the JA3 and JA4 algorithms, the JA4H layout: all published, all reproducible against your own captures with open-source tools. What is not public is the scoring. No vendor documents the exact weight it puts on an HTTP/2 mismatch versus a header-order mismatch, the threshold at which a contradiction becomes a block rather than a challenge, or the full set of dynamic behavioural signals it watches on the connection. Those are inferred from vendor blog posts, from the structure of the fingerprints, and from observed behaviour, not read from a spec. Where this post named a specific value (Chrome’s m,a,s,p pseudo-header order, the 15663105 window increment, the v0.6.1 release date), that came from a fetched source. Where it described how scoring combines the layers, that is the reasonable inference, and it is labelled as such.

The seam is the signal

The thing to take from all of this: a perfect ClientHello is necessary and nowhere near sufficient. curl-impersonate and uTLS solved the TLS layer years ago, and that solution is real. JA3 and JA4 alone cannot tell their output from a browser. But detection stopped living at the TLS layer the moment vendors started computing a second fingerprint one frame later, in the HTTP/2 SETTINGS, and a third in the header order, and cross-checking all of them against the version the User-Agent claims. The forgery is total at the bottom of the stack and partial above it, and the detector scores the disagreement.

That structure has a property worth sitting with. It does not require the detector to recognize any specific tool. It does not need a blocklist of known-bad fingerprints or a signature for curl-impersonate or for uTLS. It only needs to notice that a request’s TLS layer claims one identity while its HTTP/2 frames, its headers, and its version claims point somewhere else. A new impersonation library that nobody has seen still has to assemble its layers from separate pieces of software, and unless every one of those pieces is patched into agreement and kept in lockstep with a browser that ships a new build every few weeks, the seam shows. The cheapest tell of all, version drift, costs the detector nothing and arrives on schedule whether anyone is watching or not.


Sources & further reading

  • Elad Shuster et al. (2017), Passive Fingerprinting of HTTP/2 Clients — the Akamai Black Hat EU whitepaper defining the four-part HTTP/2 fingerprint (SETTINGS, WINDOW_UPDATE, PRIORITY, pseudo-header order).
  • lwthiker (2022), Impersonating Chrome, too — the curl-impersonate author on patching BoringSSL, GREASE, ALPS, and the HTTP/2 SETTINGS gaps, including Chrome’s “unknown random setting”.
  • Daniel Stenberg (2022), curl’s TLS fingerprint — curl’s author on what determines a client’s TLS fingerprint and the arms race around mimicking browsers.
  • Akamai SIRT (2019), Bots Tampering with TLS to Avoid Detection — Akamai on cipher stunting and why temporal, cross-layer analysis beats static JA3.
  • John Althouse / FoxIO (2023), JA4+ Network Fingerprinting — the JA4+ suite and the argument that combining fingerprints across layers defeats single-layer spoofing.
  • FoxIO (2023), JA4H technical details — the JA4H HTTP-client fingerprint format, including header-order hashing.
  • FoxIO (2024), JA4T: TCP Fingerprinting — fingerprinting the TCP layer (window size, options, scale) as an OS signal independent of the application.
  • lexiforest (2024-2026), curl-impersonate (active fork) — the maintained fork tracking newer Chrome, Firefox, and Safari profiles, ECH, ZSTD, and post-quantum curves.
  • lwthiker (2024), curl-impersonate — the original project; v0.6.1 (March 2024) tops out near Chrome 116, illustrating profile drift.
  • Scrapfly (2025), HTTP/2 and HTTP/3 Fingerprinting: Protocol-Level Bot Detection — worked examples of Chrome vs Firefox HTTP/2 fingerprints and the cross-layer validation model.
  • Trickster Dev (2023), Understanding HTTP/2 fingerprinting — a clear breakdown of the Shuster fingerprint components with examples.
  • wi1dcard (2023), fingerproxy — an open reverse proxy that computes JA3, JA4, and the Akamai HTTP/2 fingerprint and forwards them as headers.
  • curl_cffi (2026), TLS, HTTP/2 and HTTP/3 fingerprinting — documentation on how the Python binding reproduces browser fingerprints across layers.

Further reading