HTTP/2 fingerprinting: the SETTINGS frame, window size, and the Akamai format
A browser tells you who it is before it sends a single request header. Not in the User-Agent, not in a cookie, but in the shape of the first few frames it puts on an HTTP/2 connection. Every client that speaks h2 has to send a SETTINGS frame as part of the connection preface, and the values it chooses, the order it lists them in, whether it bumps the connection flow-control window, whether it bothers with priority frames, and the order it writes its pseudo-headers are all defaults baked into the networking stack. Chrome’s defaults are not Firefox’s. Firefox’s are not Safari’s. And almost nothing a normal HTTP library ships with looks like any of them.
That observation is the whole game. A server reading the first round-trip of an h2 connection can place the client into one of a few hundred buckets, and the bucket it lands in either matches the User-Agent the client claims or it does not. When it does not, you have a bot wearing a Chrome costume that forgot to change its socks. This post is the reference for that signal: what the bytes are, where they come from in the RFCs, how Akamai turned them into a single comparable string in 2017, and what each major client actually emits as of early 2026.
The plan: first the connection preface and why the SETTINGS frame is unavoidable, then each of the four fields Akamai’s format records, then the format string itself and how it gets hashed, then the concrete values for Chrome, Firefox, Safari, and the HTTP libraries, then what changed when RFC 9113 deprecated priority, and finally how the anti-bot vendors actually use the result. Nothing here is a bypass recipe. The point is to understand the signal well enough to know why spoofing it is harder than copying a string.
The connection preface forces the issue
HTTP/2 does not let a client stay quiet. RFC 7540, and its 2022 replacement RFC 9113, both require that after the TLS handshake the client opens with a fixed 24-octet connection preface, the literal bytes PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n, and that this preface is immediately followed by a SETTINGS frame. The SETTINGS frame may be empty. In practice no real browser sends it empty, because the whole point of SETTINGS is to advertise the limits and buffer sizes the client wants the server to respect for the life of the connection.
So the fingerprintable surface exists by construction. Before any GET is parsed, before the :path is even read, the server has already received a frame whose contents are a direct readout of the client’s networking configuration. That is what “passive” means in this context. The server collects nothing, injects nothing, and runs no challenge. It just reads the frames the client was always going to send.
This is the h2 sibling of the TLS ClientHello fingerprint. The TLS handshake already gave the edge a JA3 or JA4 hash before the ALPN negotiation even settled on h2. The h2 fingerprint arrives a round-trip later and adds an independent set of bits. The two are collected at different layers by different code, which is exactly why a mismatch between them is such a strong bot tell, a theme picked up in the server-side TLS fingerprinting libraries post and in how Cloudflare uses TLS and HTTP/2 fingerprints together.
The SETTINGS frame, parameter by parameter
A SETTINGS frame payload is a list of parameters, each one an unsigned 16-bit identifier followed by an unsigned 32-bit value. RFC 7540 defines six identifiers, and RFC 9113 carried all six forward unchanged. Their numeric IDs and spec defaults are the anchor for everything downstream, so they are worth stating exactly.
SETTINGS_HEADER_TABLE_SIZE is 0x1, the maximum size in octets of the HPACK header-compression table, default 4,096. SETTINGS_ENABLE_PUSH is 0x2, a boolean for server push, default 1 (permitted). SETTINGS_MAX_CONCURRENT_STREAMS is 0x3, with no limit by default. SETTINGS_INITIAL_WINDOW_SIZE is 0x4, the per-stream flow-control window, default 2^16-1, which is 65,535 octets. SETTINGS_MAX_FRAME_SIZE is 0x5, default 2^14, which is 16,384. And SETTINGS_MAX_HEADER_LIST_SIZE is 0x6, advisory, with no default limit.
The fingerprint value comes from two facts about how clients populate this list. First, no two networking stacks pick the same combination of values. Chrome raises the HPACK table to 65,536 and the initial window to 6,291,456; Firefox raises the table to 65,536 too but sets a far smaller initial window. Second, and this is the part people miss, clients differ in which parameters they bother to send at all and in what order they send them. A client that sends 1, 2, 4, 6 and omits 3 and 5 is distinguishable from one that sends all six, even before you look at a single value. The Akamai format preserves both the values and their order precisely because the absence and the sequence carry as much information as the numbers.
WINDOW_UPDATE: the connection-level window bump
The second field Akamai records is the WINDOW_UPDATE increment on stream 0.
Here is the subtlety. SETTINGS_INITIAL_WINDOW_SIZE controls the per-stream flow-control window, but it does not touch the connection-level window, which always starts at the spec default of 65,535 octets. A client that wants a bigger connection-level receive window, and every browser does, because 64 KB across the whole connection would throttle a modern page load badly, has to raise it explicitly with a WINDOW_UPDATE frame on stream 0 sent right after the preface.
The increment a client chooses is a fixed number characteristic of its stack. Chrome sends a WINDOW_UPDATE that takes the connection window to roughly 15 MB; the increment value observed is 15,663,105. Firefox sends 12,517,377, taking the window to about 12 MB. A client that never sends a connection-level WINDOW_UPDATE at all, which describes a lot of plain HTTP libraries, records a 0 in this field. So this single integer cleanly separates three populations: Chrome-family, Firefox, and “did not bother.” The exact increment is stable enough across builds that it works as a near-direct stack identifier on its own.
Priority frames and the dependency tree
The third field is where browsers historically diverged the most, and where the spec has since moved underneath everyone.
RFC 7540 defined a priority scheme built on the PRIORITY frame. Each frame carried an exclusive flag (one bit), a 31-bit stream dependency identifier naming a parent stream, and an 8-bit weight, where the wire value 0 to 255 maps to a logical weight of 1 to 256, default 16. Clients used this to build a tree telling the server which streams to service first when bandwidth was scarce. CSS and the main document before images, that kind of thing.
Browsers implemented this very differently, and that difference is gold for a fingerprinter. Chrome, in its older h2 implementation, expressed priority through the HEADERS frames it sent for each request rather than standalone PRIORITY frames, and modern Chrome records a 0 in this field because it sends no separate PRIORITY frames at connection open. Firefox does the opposite and builds an explicit dependency tree at startup. By default Firefox opens a set of “phantom” streams with odd IDs (streams 3, 5, 7, 9, 11, 13) that carry no request, exist only as anchors in the tree, and then parents real request streams onto them with specific weights. The observed pattern parents stream 9 onto stream 7, stream 11 onto stream 3, and assigns weights drawn from a small fixed set including 201, 101, 1, and 241. That arrangement is a Firefox signature you can read off the first few frames with no ambiguity.
*Firefox opens phantom anchor streams (3, 5, 7, 9...) and parents request streams onto them. The tree shape and weights are a stable signature. Schematic; exact parentage varies by version.*The priority field is covered in more depth in the HTTP/2 priority and dependency trees post, which goes through the tree shapes client by client. For the Akamai string, the thing to hold onto is that this field can be empty (recorded as a single 0) or can hold one or more comma-separated tuples, and that the difference between empty and populated already splits Chrome-family from Firefox.
Pseudo-header order: the cheapest of the four bits
The fourth field is the order of the HTTP/2 pseudo-headers in the request’s HEADERS frame.
Every h2 request carries four pseudo-headers: :method, :authority, :scheme, and :path. The spec requires them to come before regular headers but does not pin their order relative to each other, so each client emits them in whatever sequence its code happens to write. Akamai abbreviates each to a single letter: m for :method, a for :authority, s for :scheme, p for :path. The observed orders cluster tightly by client. Chrome emits m,a,s,p. Firefox emits m,p,a,s. Safari emits m,s,p,a. curl emits m,p,s,a. Four browsers, four orders, and that is before you combine this with the other three fields.
Pseudo-header order is the easiest of the four to copy, since it is just a string emitted by request-building code, and it is the field most HTTP libraries that aim to impersonate browsers fix first. It is also the subject of its own pseudo-header ordering post. The reason it still has discriminating power is that getting the order right while getting the SETTINGS values, the window bump, and the priority tree wrong produces an internally inconsistent fingerprint, and inconsistency is what the scoring engine is actually looking for.
The Akamai format string
In 2017, Akamai’s threat-research group, Ory Segal and Elad Shuster, presented “Passive Fingerprinting of HTTP/2 Clients” at Black Hat Europe and at OWASP AppSec USA. The work drew on more than 10 million HTTP/2 connections, from which they pulled fingerprints for over 40,000 distinct user agents across hundreds of implementations. The paper’s lasting contribution is not the discovery that h2 clients differ, which was obvious, but the canonical string format that made those differences comparable and hashable.
The format concatenates the four fields with pipe separators, in this order:
S[;] | WU | P[,]# | PS[,]S is the SETTINGS list, each parameter written as id:value and joined by semicolons, in the client’s transmission order. WU is the WINDOW_UPDATE increment on stream 0, or 0 if none was sent. P is the priority field, one or more StreamID:Exclusivity:DependentStreamID:Weight tuples joined by commas, or 0 if no PRIORITY frames were sent. PS is the pseudo-header order as comma-joined single letters. Put together, a full fingerprint for a current Chrome looks like:
1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,pRead that left to right. SETTINGS advertises HEADER_TABLE_SIZE 65536, ENABLE_PUSH disabled, INITIAL_WINDOW_SIZE 6291456, MAX_HEADER_LIST_SIZE 262144, and notably omits 0x3 and 0x5. The window bump is 15663105. No standalone priority frames, so 0. Pseudo-headers in m,a,s,p order. Every one of those values is a Chrome default, and a request claiming to be Chrome that produces a different string in any field is suspect.
The raw string is the canonical artefact, but storing and comparing variable-length strings at edge scale is wasteful, so most implementations hash it, conventionally with MD5, exactly as a JA3 string collapses to a JA3 hash. The hash is what lands in a lookup table keyed against a library of known-good browser fingerprints. The string is what you reason about; the hash is what the database joins on. Public demo endpoints like tls.peet.ws expose both the raw akamai-fingerprint string and its hash for any browser you point at them, which is how the community keeps the reference values current.
What each client actually emits
The values below are drawn from the public fingerprint catalogs and from the published research. Treat the SETTINGS lists and window increments as stable across point releases but liable to shift on major networking-stack changes, which is the normal failure mode for any of these signals.
Chrome and the wider Chromium family (Edge, Opera, Brave, and Electron apps) send the SETTINGS list 1:65536;2:0;4:6291456;6:262144, a WINDOW_UPDATE increment of 15663105, no priority frames, and pseudo-header order m,a,s,p. The disabled 2:0 for server push and the omission of 0x3 and 0x5 are characteristic. Chromium standardised on this shape after it removed the older RFC 7540 priority signalling.
Firefox sends a different SETTINGS list, raising the HPACK table to 65536 and setting a smaller initial window of 131072 along with 5:16384 for max frame size, then a WINDOW_UPDATE increment of 12517377, then the anchor-stream priority tree described above, and pseudo-header order m,p,a,s. The priority tree is the field that most cleanly separates Firefox from everything else, because almost nothing else still builds one.
Safari and the WebKit stack emit their own SETTINGS combination and pseudo-header order m,s,p,a. Safari’s exact SETTINGS values vary more across macOS and iOS versions than Chrome’s do, so the safest Safari discriminator is the pseudo-header order combined with the absence of the Firefox tree. The exact per-version SETTINGS layout for Safari is less consistently documented in public sources than Chrome’s or Firefox’s, and what circulates is largely captured from live traffic rather than from a vendor specification.
The HTTP libraries are where the signal earns its keep. curl built on nghttp2 sends its own SETTINGS shape and pseudo-header order m,p,s,a, which matches no browser. Python’s httpx, Go’s net/http, Node’s built-in h2 client, Java’s HttpClient, and the rest each carry their library’s defaults, and none of them coincidentally land on a browser fingerprint. This is the same problem Python requests has at the TLS layer before it sends a byte of HTTP, only one layer up. A client that presents a Chrome User-Agent over a connection whose SETTINGS frame is nghttp2’s defaults is, to the edge, transparently not Chrome.
What RFC 9113 changed, and what it did not
RFC 9113 obsoleted RFC 7540 in June 2022, and it did one thing that matters directly to this fingerprint: it deprecated the priority signalling scheme. The text is explicit that the RFC 7540 priority mechanism, the dependency tree and weights, is deprecated, while retaining the PRIORITY frame’s wire format for interoperability and pointing endpoints toward an alternative scheme, which became the priority header field and the PRIORITY_UPDATE frame defined in RFC 9218.
What that did not do is erase the fingerprint. Deprecation is a recommendation to implementers, not a flag day. Firefox kept emitting its anchor-stream tree well past the deprecation because its h2 stack predated the change and the tree still works. So the priority field of the Akamai fingerprint became, if anything, more discriminating after 2022, because building an RFC 7540 dependency tree at all now positively identifies a stack that has not migrated, which in practice means Firefox and a shrinking set of older clients. The fingerprint surface did not narrow; it just shifted which clients each value points to. That is the recurring pattern with every one of these signals. A spec change reshuffles the buckets without emptying them.
How the vendors use it
A passive h2 fingerprint is rarely a verdict on its own. It is one input, weighted alongside the TLS fingerprint collected a round-trip earlier, the HTTP header order and casing, the IP reputation, and whatever JavaScript telemetry the page later returns. The power of the h2 signal is correlation. The edge already has a TLS JA4 that claims Chrome; if the h2 SETTINGS frame says nghttp2, the two disagree, and disagreement between independently collected layers is the cleanest bot tell there is, because a real Chrome cannot produce that combination. DataDome leans on exactly this cross-layer consistency check, described in how DataDome uses HTTP/2 and network fingerprints, and Cloudflare folds the h2 fingerprint into its bot management score the same way.
This is why copying the Akamai string is a losing move on its own. You can set your pseudo-header order to m,a,s,p in a line of code. Matching the WINDOW_UPDATE increment is another constant. But producing the whole fingerprint, the right SETTINGS values in the right order with the right omissions, the right window bump, no priority tree, plus a matching TLS ClientHello, plus matching header order, plus consistent JavaScript, from a stack that is not actually Chromium, means reimplementing enough of Chrome’s network layer that you may as well drive real Chrome. The clients that get this right, the curl-impersonate family and uTLS-based tooling, do it by lifting the actual byte sequences out of the browser, not by guessing them, and even then a single field drifting out of sync with a new browser release reopens the gap. The catch-up cycle is covered in detecting curl-impersonate and uTLS.
Closing
The h2 fingerprint is durable for an unglamorous reason: it is a side effect of code nobody wrote to be unique. The Chromium engineer who set the connection window to 15 MB was tuning throughput, not minting an identifier, and the Firefox engineer who built the anchor-stream tree was chasing render order. Akamai’s 2017 contribution was to notice that these accidents are stable, distinct, and visible in the first round-trip, then to write them down in a format you can hash and join on. Nine years later the format is unchanged and sits inside every major bot-management product, even as the RFC underneath it deprecated one of its four fields.
The one number worth remembering is the asymmetry. A defender reads four fields off frames the client had to send anyway, for free, before a single request header is parsed. An attacker who wants those four fields to lie has to reproduce a browser’s entire networking stack and keep it in lockstep with the real browser’s release train, where any one drifting value flips the consistency check. That gap is why protocol-level fingerprinting outlived the JavaScript-only era of bot detection, and why the SETTINGS frame, a thing the spec made mandatory for flow control, turned out to be one of the hardest tells to fake.
Sources & further reading
- Segal, O. and Shuster, E., Akamai Threat Research (2017), Passive Fingerprinting of HTTP/2 Clients — the whitepaper that defined the S|WU|P|PS format, drawn from 10M+ connections and 40,000+ user agents.
- Belshe, M., Peon, R. and Thomson, M. (2015), RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) — the original spec defining the SETTINGS, WINDOW_UPDATE, and PRIORITY frames and the connection preface.
- Thomson, M. and Benfield, C. (2022), RFC 9113: HTTP/2 — the June 2022 replacement that deprecated the RFC 7540 priority signalling while keeping the frame format.
- lwthiker (2022), HTTP/2 fingerprinting: A relatively-unknown method for web fingerprinting — detailed walkthrough of Chrome, Firefox, and curl SETTINGS, window increments, and the Firefox priority tree.
- Scrapfly (2024), HTTP/2 and HTTP/3 Fingerprinting: Protocol-Level Bot Detection — vendor-side view of the Akamai format, per-browser values, and how the string gets hashed.
- Trickster Dev (2023), Understanding HTTP/2 fingerprinting — concise breakdown of the S/WU/P/PS components and the SETTINGS parameter IDs.
- pagpeter, TrackMe (tls.peet.ws) — open-source passive-fingerprinting demo that exposes the live akamai-fingerprint string and hash for any browser.
- BrowserLeaks, HTTP/2 Fingerprinting — live tool showing the Akamai fingerprint and hash for the visiting client.
- Scrapfly, HTTP/2 Fingerprint tool — reference values for the SETTINGS frame, window update, priority, and pseudo-header order per client.
- Xetera, nginx-http2-fingerprint — an NGINX implementation of the Akamai whitepaper, useful for seeing the capture logic in code.
Frequently asked questions
What four fields make up the Akamai HTTP/2 fingerprint string?
The format concatenates four fields separated by pipes in the order S, WU, P, PS. S is the SETTINGS list, each parameter written as id:value joined by semicolons in the client's transmission order. WU is the WINDOW_UPDATE increment on stream 0, or 0 if none was sent. P is the priority field, one or more StreamID:Exclusivity:DependentStreamID:Weight tuples joined by commas, or 0 if no PRIORITY frames were sent. PS is the pseudo-header order as comma-joined single letters.
Why can a server fingerprint an HTTP/2 client without sending any challenge?
HTTP/2 does not let a client stay quiet. After the TLS handshake the client must send a fixed 24-octet connection preface immediately followed by a SETTINGS frame, before any GET is parsed or any path is read. The frame advertises the client's chosen limits and buffer sizes, which are defaults baked into its networking stack. The server collects nothing and injects nothing; it just reads frames the client was always going to send, which is what passive means here.
How does the WINDOW_UPDATE field separate clients in the fingerprint?
SETTINGS_INITIAL_WINDOW_SIZE controls the per-stream window but leaves the connection-level window at the spec default of 65,535 octets, so browsers raise it explicitly with a WINDOW_UPDATE on stream 0. Chrome sends an increment of 15,663,105, taking the window to roughly 15 MB, while Firefox sends 12,517,377 for about 12 MB. A client that never sends one, which describes many plain HTTP libraries, records a 0. This single integer cleanly separates Chrome-family, Firefox, and clients that did not bother.
Did RFC 9113 deprecating priority weaken the HTTP/2 priority fingerprint?
RFC 9113 deprecated the RFC 7540 priority signalling in June 2022 but kept the PRIORITY frame's wire format for interoperability. Deprecation is a recommendation, not a flag day, so Firefox kept emitting its anchor-stream dependency tree well past it. The priority field became more discriminating as a result, because building an RFC 7540 tree at all now positively identifies a stack that has not migrated. The fingerprint surface did not narrow; it just shifted which clients each value points to.
Why is copying the Akamai fingerprint string not enough to evade detection?
A passive HTTP/2 fingerprint is weighted alongside the TLS fingerprint collected a round-trip earlier, header order and casing, IP reputation, and later JavaScript telemetry. The power is correlation: if the TLS layer claims Chrome but the SETTINGS frame says nghttp2, the independently collected layers disagree, which a real Chrome cannot produce. Reproducing every field in lockstep means reimplementing enough of Chrome's network layer that you may as well drive real Chrome, and any single value drifting out of sync on a new release reopens the gap.
Further reading
How DataDome uses HTTP/2 and network fingerprints as a signal
A reference on the network-layer fingerprints DataDome reads: HTTP/2 SETTINGS frames, flow control, pseudo-header order, and how a mismatch between the claimed user agent and the wire profile flags a client.
·21 min readHow HTTP/2 frame fingerprinting catches HTTP clients pretending to be browsers
How a forged TLS handshake plus a generic HTTP/2 library still contradicts itself at the frame level, and how anti-bot systems turn that cross-layer mismatch into a bot verdict.
·19 min readDataDome's detection model: every signal it collects on the first request
Traces what DataDome evaluates on the very first request, before any JavaScript runs: the TLS/JA4 fingerprint, the HTTP/2 frame profile, the header set, and IP and ASN reputation, and how those signals stack into one decision.
·19 min read