Skip to content

How HTTP/2 frame fingerprinting catches HTTP clients pretending to be browsers

· 19 min read
Copyright: MIT
A Chrome TLS fingerprint over a mismatched HTTP/2 SETTINGS frame

A scraper can buy itself a perfect Chrome TLS handshake. The cipher list, the extension order, the GREASE values, the supported groups, all of it copied byte for byte out of a real Chrome so that the JA3 or JA4 hash the edge computes lands exactly on the value Chrome would produce. That part is a solved problem. Libraries do it well, and the handshake closes, ALPN settles on h2, and the connection is up. The disguise is convincing right until the moment the client has to start speaking HTTP/2. Because the TLS layer and the HTTP/2 layer are usually two different pieces of code, written by two different sets of people, and they were never asked to agree with each other.

That is the whole detection story. A request that claims to be Chrome on the User-Agent, presents a Chrome ClientHello to the TLS stack, and then opens its HTTP/2 connection with a SETTINGS frame that no Chrome ever sent has told the server three different things about what it is. The anti-bot system does not need to break any one of those signals. It only needs to notice that they disagree. This post is about that disagreement: where in the connection it shows up, why it is so hard to make go away, and how the detection side reads it.

The plan: first, why TLS and HTTP/2 are separate fingerprints collected by separate code, and why that separation is the weak point. Then the specific frames that leak, the values real browsers send versus the values libraries send, and the cross-layer consistency check that ties User-Agent to TLS to HTTP/2. Then what RFC 9113 did to the priority signal and what that broke for both sides. Then how vendors actually score the mismatch, and where the impersonation tools stand in 2026. Nothing here is a working bypass; the point is to understand why the contradiction is structural, not a bug somebody will patch next week.

Two fingerprints, two codebases, one connection

When a browser connects over HTTPS with HTTP/2, there are at least two independent fingerprintable events on the wire before the first response byte comes back. The first is the TLS ClientHello, captured at the handshake. That gives the edge a JA3 or JA4 hash describing the cipher suites, extensions, elliptic curves, and signature algorithms the client offered. The second arrives one round-trip later: the HTTP/2 connection preface and the SETTINGS frame that the spec requires the client to send immediately after it. That gives a second, independent hash describing the client’s HTTP/2 configuration.

In a real browser those two events come from the same shipped binary. Chrome’s TLS comes from BoringSSL and its HTTP/2 comes from the network service inside Chromium, but both were built, tested, and released together as one product. They are guaranteed consistent because Google ships them as a unit. A request that has Chrome’s TLS fingerprint will have Chrome’s HTTP/2 fingerprint, every time, on that version.

In a scraper they almost never come from the same binary. The usual shape is a TLS-impersonation layer bolted underneath a general-purpose HTTP stack. The TLS layer is patched or replaced so the handshake looks like Chrome. The HTTP/2 stack above it is whatever the language ecosystem provides, Go’s net/http, Python’s h2 or httpx, Node’s built-in http2, and that stack sends its own defaults. Those defaults were chosen by a library author optimising for correctness and throughput, not to match any browser. So the connection presents Chrome at the TLS layer and Go at the HTTP/2 layer, and the two halves describe two different programs.

One connection, three claims about identity: User-Agent header "...Chrome/144.0.0.0 Safari/537.36" TLS ClientHello (JA4) forged -> matches Chrome 144 HTTP/2 SETTINGS frame library defaults -> not Chrome consistency check TLS says Chrome, h2 says otherwise *The forged TLS layer and the library HTTP/2 layer describe two different programs; the server only has to notice they disagree.*

The asymmetry is what makes this work for the defender. The attacker has to get every layer right simultaneously and keep them aligned across browser releases. The defender only has to find one layer that is wrong. Copying a TLS fingerprint is a known recipe, covered in the browser ClientHello catalog and in why your Python requests is fingerprintable before it sends a byte of HTTP. The HTTP/2 layer is a second recipe that has to be solved by the same client, in lockstep, or the whole thing folds.

What the frames leak

The mechanics of the HTTP/2 fingerprint, the SETTINGS frame, the window-update increment, the priority frames, the pseudo-header order, and the S|WU|P|PS string that ties them together, are the subject of the HTTP/2 fingerprinting Akamai-format reference. Here the relevant question is narrower: which of those fields actually separate a browser from a library, and by how much. The answer is that nearly all of them do, and the gaps are not subtle.

The single most reliable tell is SETTINGS_INITIAL_WINDOW_SIZE, parameter 0x4. This is the per-stream flow-control window, and the spec default is 65,535 octets. Browsers raise it dramatically. Chrome sets it to 6,291,456, roughly 6 MB, because it wants large response bodies to flow without constant window-update stalls. Firefox sets a much smaller value, around 131,072. Most HTTP libraries leave it at or near the 65,535 default, or pick some round number of their own. Go’s net/http2 historically used the 65,535 default. The difference between Chrome’s 6,291,456 and a library’s 65,535 is about a hundredfold, and it sits in plain integer form in the first frame of the connection. A request claiming to be Chrome that advertises a 64 KB initial window has effectively signed a confession.

The set of parameters a client sends, and the order it sends them in, is the second tell, and it is independent of the values. Chrome sends a specific subset and notably omits SETTINGS_MAX_CONCURRENT_STREAMS (0x3) and SETTINGS_MAX_FRAME_SIZE (0x5) from its client SETTINGS. A library that dutifully sends all six parameters, or sends them in numeric ID order rather than the browser’s order, is distinguishable before you read a single value. The Akamai format preserves the sequence exactly because absence and ordering carry as much signal as the numbers do.

SETTINGS_INITIAL_WINDOW_SIZE (0x4), octets advertised in the first frame Chrome 6,291,456 Firefox 131,072 library default 65,535 (spec default) bars are log-scaled for legibility; the real gap is ~100x *A client claiming Chrome but advertising a 64 KB window has contradicted itself in the first frame it sends.*

Then there is the connection-level WINDOW_UPDATE. After the SETTINGS frame, browsers immediately send a WINDOW_UPDATE on stream 0 to enlarge the whole connection’s flow-control window beyond the per-stream default. Chrome’s increment is a specific large value; the Akamai-style fingerprint records it as a single integer (Chrome’s lands around 15,663,105 in current builds). Many libraries never send a connection-level WINDOW_UPDATE at all, which the fingerprint records as 0. A zero in that field against a Chrome User-Agent is another standalone contradiction.

The pseudo-header order is the fourth field, and it is purely a software tell with no performance rationale at all. HTTP/2 carries the request line as four pseudo-headers, and the order a client serialises them is fixed per implementation. Chrome sends :method, :authority, :scheme, :path. Firefox sends :method, :path, :authority, :scheme. Safari sends :method, :scheme, :path, :authority. These orders are baked into each browser’s HPACK encoder. A library that emits them in any other sequence, or in the alphabetical or spec-listed order, is identifiable from the order alone. The pseudo-header ordering fingerprint post treats this field on its own; for detection purposes it is one more thing the forged client has to get right and usually does not.

The gap between browser and library framing is not a recent or marginal one. Segal’s original 2017 measurements already separated common non-browser clients cleanly by these fields alone. okhttp 3.6.0 fingerprinted as 4:16777216 | 16711681 | 0: a single SETTINGS parameter, a window-update increment, and no priority frames. curl 7.54.0 came out as 3:100;4:1073741824 | 1073676289 | 0, advertising a one-gigabyte initial window that no browser would ever set. Neither of those looks remotely like Chrome’s four-parameter SETTINGS, its large connection window-update, and its pseudo-header order. The values have drifted since 2017 as both browsers and libraries changed, but the shape of the difference has not: each networking stack still picks a combination of parameters, values, and ordering that is its own, and almost none of those combinations coincides with a browser’s.

The consistency check is the product

Reading any one of those fields gives you a client class. The detection value comes from comparing the class to the other things the request claims about itself. The User-Agent string says “Chrome 144.” The TLS fingerprint says “Chrome 144” (because it was forged to). The HTTP/2 fingerprint says “Go standard library” or “Python h2.” A rule that simply asks whether these three agree catches a large fraction of impersonation traffic without ever needing to know what a bot looks like in the abstract. It only needs a table of what each real browser version emits at each layer, and a request that falls outside the joint distribution is anomalous by definition.

This is cheaper and more durable than behavioural detection. There is no JavaScript challenge, no proof-of-work, no waiting for mouse movement. The decision can be made on the first request, before any application code runs, from bytes the client was forced to send by the protocol. That is why the major vendors collect it. Cloudflare folds TLS and HTTP/2 fingerprints into its bot score, described in how Cloudflare uses TLS and HTTP/2 fingerprints. DataDome treats the HTTP/2 fingerprint as a first-request network signal, covered in how DataDome uses HTTP/2 and network fingerprints. Akamai, which originated the format, scores it as part of Bot Manager. The signal is collected at the edge before the request is even routed to an origin, which is exactly the property that makes the server-side TLS fingerprinting libraries and their HTTP/2 equivalents worth deploying inline.

A subtlety worth stating plainly: the consistency check does not require the defender to have a name for the bot. It does not matter whether the HTTP/2 fingerprint belongs to a known scraping framework or some bespoke tool nobody has catalogued. The fingerprint just has to fail to match the TLS fingerprint and User-Agent it is paired with. Unknown-but-inconsistent is enough to raise the score. This inverts the usual signature-detection problem, where the defender is always one step behind a new tool. With cross-layer consistency the defender wins by default and the attacker has to actively produce a match.

request arrives UA + TLS + h2 captured does the h2 fingerprint match the claimed browser? no yes pass cross-layer contradiction no name for the bot required raise bot score / challenge *Unknown-but-inconsistent is enough. The defender does not need a signature for the specific tool, only a model of what each real browser emits.*

What RFC 9113 changed, and what it did not

The original HTTP/2 spec, RFC 7540 from 2015, defined a stream-priority system: clients could build a dependency tree by sending PRIORITY frames, declaring which streams depended on which and with what weight. Browsers built distinctive trees. Firefox sent a recognisable set of PRIORITY frames laying out a small dependency graph; Ory Segal’s 2017 Akamai work recorded Firefox’s tree as the sequence 3:0:0:201, 5:0:0:101, 7:0:0:1, 9:0:7:1, 11:0:3:1, a five-frame structure no library reproduced by accident. That tree was a strong, separate fingerprint field for years.

RFC 9113 changed the situation in June 2022. It replaced RFC 7540 as the definitive HTTP/2 spec and deprecated the RFC 7540 priority signalling outright, on the grounds that the scheme was complex and never uniformly implemented. The replacement, RFC 9218, defines an extensible prioritisation scheme using a Priority header field and a new SETTINGS_NO_RFC7540_PRIORITIES setting that lets a client announce it has abandoned the old tree-based scheme. Browsers followed: modern Chrome stopped sending the old PRIORITY-frame tree and moved toward the header-based scheme, while Firefox’s behaviour shifted over its own release cycle.

For detection this cut both ways. The rich dependency-tree fingerprint that distinguished Firefox so cleanly is fading as browsers drop RFC 7540 priorities, so that one field carries less information than it did in 2017. The HTTP/2 priority and dependency trees post treats that decline directly. But the deprecation also opened a fresh field. Whether a client sends SETTINGS_NO_RFC7540_PRIORITIES, whether it sends the old PRIORITY frames anyway, and whether it emits a Priority request header are all new discriminators. A library frozen on a pre-2022 HTTP/2 stack still emits the old tree, or no priority signal at all, while a current browser has moved on. The deprecation did not shrink the fingerprint surface. It moved it, and any client that did not move with it is now anomalous in a new way.

This is the recurring pattern in protocol fingerprinting. A specific field loses value as implementations converge or a feature is removed, and the same change creates a new field measuring who adopted the new behaviour and when. The surface migrates; it does not close. The same dynamic plays out one layer down in TLS, where the Chrome extension-order randomisation that broke JA3 simply pushed the signal into JA4’s sorted form, traced in TLS fingerprinting from ClientHello bytes to JA4.

Why the contradiction is structural

It would be easy to read all of this as a list of bugs a determined scraper will fix one by one. The window size is wrong, so patch the window size. The pseudo-header order is wrong, so reorder the encoder. Catch them all and the mismatch vanishes. That underestimates the problem in two ways.

First, the HTTP/2 stack in most languages does not expose these knobs. The window size, the SETTINGS subset, the connection-level WINDOW_UPDATE behaviour, and the pseudo-header serialisation order are internal implementation details of the library, not configuration surface. Go’s net/http2, Python’s h2, and Node’s http2 were written to be correct HTTP/2 endpoints, not to impersonate a specific browser’s framing, and they do not offer an API to make the SETTINGS frame come out in Chrome’s order with Chrome’s values. To control the framing you have to either patch the library, fork it, or replace it with one purpose-built for impersonation. That is a much larger undertaking than overriding a header.

Second, even a stack that does expose the knobs has to track a moving target. Browser HTTP/2 framing changes across releases. Chrome’s window size, its SETTINGS subset, and its priority behaviour have all shifted over the years; the RFC 9113 priority deprecation alone forced a framing change. A fingerprint that matched Chrome 120 will not match Chrome 144, and the version in the forged User-Agent has to match the version of the framing being emitted, or the consistency check fails on the version axis instead of the browser axis. The maintainer of an impersonation library is signing up to chase every browser’s framing across every release, forever, for every browser they claim to support.

This is the same arms race the TLS-impersonation tools live inside, and the people who build them are candid about it. Daniel Stenberg, curl’s maintainer, has written about how curl’s own TLS fingerprint differs from a browser’s and why making curl indistinguishable from Chrome is a continuous effort rather than a one-time patch. The projects that exist for this, curl-impersonate and its curl_cffi Python binding, fhttp, and the various Go tls-client forks, all carry a browser-framing profile alongside the TLS profile precisely because the two have to be shipped and updated together. When a profile drifts out of date, the tool’s TLS still passes while its HTTP/2 no longer matches the browser version it claims, and the cross-layer check catches it on the gap the maintainer has not yet closed.

There is one more reason the contradiction is hard to erase: it is observed passively, so the attacker gets no feedback. A JavaScript challenge tells a bot it failed, which gives the bot author something to debug against. The HTTP/2 consistency check returns a normal response and silently raises a score. The scraper sees a 200, parses the page, and has no idea its window size gave it away. The signal that would tell the author what to fix is the one thing the defender never sends back. That makes the field expensive to reverse-engineer and cheap to keep collecting, which is the combination a detection engineer wants.

It is worth being precise about what “passive” buys the defender beyond stealth. The fingerprint is collected without any state, any cookie, or any prior interaction. The first packet of HTTP/2 data on a brand-new connection from an IP the edge has never seen carries enough to score it. That means the signal works on the very first request, before a session exists, which closes a window that behavioural detection cannot reach. Behavioural systems need a few requests to build a baseline; a mouse-movement or timing model needs the client to actually do something. The frame fingerprint needs nothing but the connection preface the protocol forced the client to send, so it covers exactly the early requests where a scraper is most likely to grab what it came for and leave. A study published in early 2026 put the discriminative power of protocol-layer fingerprints at an AUC of 0.998 for separating bad bots from real clients using TLS features alone, and HTTP/2 framing is a strictly additional, independently collected layer on top of that. The two layers do not share failure modes, which is the property that makes their agreement such a hard thing to fake.

State of play in 2026

The honest position as of early 2026 is that the gap is closable for a well-resourced operator and open by default for everyone else. A scraper running a current curl-impersonate or curl_cffi build, with a TLS profile and an HTTP/2 framing profile that both match the same recent Chrome version, can present a consistent cross-layer fingerprint and pass the consistency check. That is real, and the impersonation projects are good at it for the browser versions they actively track. The catch is the maintenance cost and the version coupling. The moment the profile lags a browser release, or the operator pairs a fresh User-Agent string with a stale framing profile, the contradiction reappears, and it reappears in a place the operator cannot see because the failure is silent.

The internal scoring weights are not public. No vendor publishes the exact penalty for an HTTP/2 fingerprint that contradicts the User-Agent, and the precise field layout each vendor extracts is inferred from observed traffic and the published Akamai format rather than documented per vendor. What is documented is that the major systems collect the HTTP/2 fingerprint on the first request, compare it against the claimed client, and treat a mismatch as a meaningful bot signal rather than a curiosity. The direction of travel is toward more layers in the joint distribution, not fewer: TLS, HTTP/2, and increasingly the QUIC transport parameters for HTTP/3, where the same forge-one-layer problem reappears in a new place and is traced in HTTP/3 and QUIC fingerprinting.

The thing to hold onto is the asymmetry. A browser is consistent across every protocol layer for free, because one vendor ships the whole stack as a unit and tests it together. A spoofed client is consistent only as far as someone has done the work to align its layers, and only until the next browser release moves one of them. The defender reads the first three frames of a connection and asks a single question: do the layers agree? For a real browser the answer is always yes. For everything else it is yes only by deliberate, ongoing effort, and the day that effort lapses, the window size in the first frame says everything.


Sources & further reading

  • Segal, O. (2017), Passive Fingerprinting of HTTP/2 Clients — the Akamai threat-research talk (CODE BLUE / Black Hat EU 2017) that defined the SETTINGS / WINDOW_UPDATE / PRIORITY / pseudo-header format and gave the first okhttp and curl example fingerprints.
  • Belshe, M., Peon, R., Thomson, M. (2015), RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) — the original spec defining the connection preface, the SETTINGS frame parameters, and the now-deprecated stream-priority dependency tree.
  • Thomson, M., Benfield, C. (2022), RFC 9113: HTTP/2 — the 2022 replacement that deprecated RFC 7540 priority signalling and carried the six SETTINGS parameters forward unchanged.
  • Oku, K., Pardue, L. (2022), RFC 9218: Extensible Prioritization Scheme for HTTP — the replacement prioritisation scheme, the Priority header field, and the SETTINGS_NO_RFC7540_PRIORITIES setting that marks a client as having dropped the old tree.
  • Scrapfly (2026), HTTP/2 and HTTP/3 Fingerprinting: Protocol-Level Bot Detection — a current walkthrough of the format with Chrome 144 example values and the TLS-versus-HTTP/2 mismatch argument.
  • LRZ Privacy Check (2023), Fingerprinting HTTP/2 — an academic reference page enumerating the SETTINGS identifiers and example browser fingerprints across Chrome and Firefox versions.
  • Trickster Dev (2023), Understanding HTTP/2 fingerprinting — a clear explainer of the four-component format and how it sits in the anti-automation toolset.
  • BrowserLeaks, HTTP/2 Fingerprint — a live tool that shows the Akamai fingerprint string and hash for whatever client visits it, useful for reading real values.
  • Stenberg, D. (2022), curl’s TLS fingerprint — curl’s maintainer on why curl’s fingerprint differs from a browser’s and why closing that gap is continuous work.
  • curl_cffi docs (2026), What is TLS and http/2, http/3 fingerprinting? — the impersonation library’s own note that HTTP/2 settings identify a request’s source and that the Akamai digest is the common method.
  • Jarad, G., Bıçakcı, K. (2026), When Handshakes Tell the Truth: Detecting Web Bad Bots via TLS Fingerprints — a recent study reporting AUC 0.998 for protocol-layer fingerprint bot detection, on the premise that protocol-layer fingerprints are hard to fake at scale.

Further reading