Skip to content

Why HTTP/2 multiplexing changed what servers can fingerprint

· 21 min read
Copyright: MIT
One multiplexed HTTP/2 connection carrying many streams, with the setup frames marked as the fingerprint surface

HTTP/1.1 and HTTP/2 ask the same question of a server, in roughly the same words, and get the same page back. What changed underneath is the connection. HTTP/1.1 spreads a page load across a handful of short-lived TCP sockets and tears them down when it is done. HTTP/2 opens one connection, negotiates a shared state for it, and runs every request for that origin through that single pipe as long as the tab stays open. That is the multiplexing story everyone knows, and it is usually told as a performance story: fewer handshakes, no head-of-line blocking at the HTTP layer, better use of one congestion window.

Here is the part that performance writeups skip. When you collapse many connections into one, you also collapse many disposable, near-anonymous sockets into one long-lived, stateful object that the client has to configure up front. That configuration is sent in the clear, once, before the first request, and then it sits there describing the client for the life of the connection. HTTP/1.1 never gave a server anything like it. The question this post answers is narrow and specific: what about multiplexing, exactly, turned the connection itself into a fingerprint, when HTTP/1.1’s many connections could not be?

The roadmap. First, what HTTP/1.1 actually exposed at the connection layer, and why it was thin. Then the mechanics of the HTTP/2 connection preface and the SETTINGS exchange, because that is where the new surface lives. Then the reuse property: why one persistent connection makes the fingerprint stable in a way a pool of sockets never could. Then the specific frames a server reads (SETTINGS, WINDOW_UPDATE, the now-deprecated PRIORITY frames, pseudo-header order), grounded in the RFCs and Akamai’s 2017 measurement. Then how RFC 9218 quietly moved one of those signals out of the connection and into a header. A closing section on what this means for anyone trying to reason about the fingerprint surface as a whole.

What HTTP/1.1 gave a server, and why it was thin

A server fielding an HTTP/1.1 request sees a request line, a block of headers, maybe a body. At the connection layer below that, it sees almost nothing the client chose. The TCP and TLS layers carry their own fingerprints, and those are real, but they belong to the operating system and the TLS library, not to the HTTP behavior. At the HTTP/1.1 layer specifically, the only thing a client genuinely configures is the set of headers it sends and the order it sends them in.

Header order and casing turned out to be a usable HTTP/1.1 signal, and it is one Crawlex has written about in its own post on header order and casing. A browser emits its headers in a fixed sequence with consistent capitalization, and a naive HTTP library emits a different sequence, often alphabetized or insertion-ordered. That difference is detectable. But it is a thin signal for two reasons. It lives in the application data, where any client that wants to lie can rewrite it freely, since headers are just text. And it carries no connection-level state, because HTTP/1.1 connections do not have negotiated, long-lived connection-level state to leak.

This is the structural point worth holding onto. HTTP/1.1 is connection-stateless in the sense that matters here. A keep-alive connection can carry several requests, but the protocol defines no shared parameters for that connection that the client must declare before it starts. There is no flow-control window to size. There is no compression table to bound. There is no stream concurrency limit to advertise, because there are no streams. Each request stands alone, and a server reading two requests on the same socket learns nothing about the client from the fact that they shared a socket. The connection is plumbing. It does not describe anyone.

And the connection is also disposable. A browser loading a page over HTTP/1.1 opens multiple parallel connections to the same origin, commonly up to six, precisely because each connection can only carry one request-response at a time without pipelining (which nobody deployed safely). Those connections come and go. Any fingerprint you could build from a single socket would be fragmented across six of them and gone the moment the page finished loading. There was never a stable, single, client-configured object to point a fingerprint at.

The connection preface: where the new surface comes from

HTTP/2 begins every connection with a fixed ritual. The client sends a connection preface: the 24-octet magic string PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n, immediately followed by a SETTINGS frame. The server’s own preface is a SETTINGS frame in return. RFC 9113, the current HTTP/2 specification, requires this exchange before any request data flows, and requires that a received SETTINGS frame be acknowledged before other frames are processed. The preface is not optional and it is not negotiable in timing. It is the first thing on the wire after the TLS handshake completes and ALPN has selected h2.

That SETTINGS frame is the new surface. It is the client declaring, out loud and up front, how it intends to run this connection. RFC 9113 defines six settings, each a 16-bit identifier paired with a 32-bit value:

SETTINGS frame — sent in the connection preface, before any request ID name RFC 9113 default 0x01 HEADER_TABLE_SIZE 4096 0x02 ENABLE_PUSH 1 0x03 MAX_CONCURRENT_STREAMS (no default; unlimited) 0x04 INITIAL_WINDOW_SIZE 65535 0x05 MAX_FRAME_SIZE 16384 0x06 MAX_HEADER_LIST_SIZE (unbounded) A client may send any subset, in any order, with any values. The subset and the values are the signal. *The six SETTINGS parameters and their spec defaults. A client picks which to send, in what order, and at what value, and those choices are stable per stack.*

The trick is that the spec defines defaults but does not require a client to accept them, nor to send every setting, nor to send them in a canonical order. A client sends the subset it cares about, in the order its networking code happens to write them, at the values its engineers chose. Chrome’s stack makes one set of choices. Firefox’s makes another. Safari’s makes a third. None of these are documented as an interface anyone should depend on, which is exactly why they are good fingerprints: nobody bothers to keep them identical to a competitor because they are supposed to be private implementation details.

A measured example makes this concrete. A current Chrome sends a SETTINGS frame that, written in Akamai’s notation, reads 1:65536;2:0;4:6291456;6:262144. It sets HEADER_TABLE_SIZE to 65536, turns ENABLE_PUSH off, raises INITIAL_WINDOW_SIZE to 6291456, and caps MAX_HEADER_LIST_SIZE at 262144. It omits settings 3 and 5 entirely. Firefox sends a different set and different values; one observed Firefox fingerprint is 1:65536;4:131072;5:16384. The point is not the exact numbers, which drift between versions. The point is that these strings differ between stacks and stay constant within a stack, which is the only property a fingerprint needs.

Look closely at what those choices encode. HEADER_TABLE_SIZE at 65536 instead of the spec default of 4096 means the client is telling its HPACK encoder it will tolerate a larger dynamic compression table; a stack that ships the bare default of 4096, or omits the setting and accepts the default implicitly, looks different on the wire whether or not it ever fills the table. ENABLE_PUSH at 0 is Chrome flatly refusing server push, a feature most of the ecosystem abandoned, and the explicit 2:0 is not the same observation as a client that simply never mentions push. INITIAL_WINDOW_SIZE governs how much data the server may send on each new stream before waiting for a window update, so a client raising it to 6291456 is configuring the read behavior of every stream the connection will ever carry. Each of these is a knob, each knob has a default the client chose to keep or override, and the combination of which knobs were touched and how is far more specific than any single value. That combinatorial width is what let Akamai pull tens of thousands of distinct fingerprints out of a population of clients that, on the User-Agent alone, would have collapsed into a few dozen browser families.

The reuse property: one connection, observed once, true for the life of the tab

This is the hinge of the whole argument, so it is worth slowing down on. The SETTINGS frame is not unique to multiplexing as a byte layout. What multiplexing changes is the lifecycle of the thing being fingerprinted.

Under HTTP/2 a browser opens, in the normal case, a single connection per origin and runs every request for that origin over it as concurrent streams. The connection is long-lived. The setup frames, SETTINGS and the initial WINDOW_UPDATE, are sent exactly once, at the start, and they govern every stream that follows. So a server reads the fingerprint a single time and then knows it holds for dozens or hundreds of subsequent requests on that same connection, with no per-request cost and no chance of the value changing midstream. The connection has identity, and that identity is declared in its first round-trip.

HTTP/1.1 — a pool of short-lived sockets, no connection-level state each socket: one request at a time, then gone. Nothing declared up front. HTTP/2 — one connection, declared once, reused for every stream SETTINGS WINDOW_UPD = fingerprint stream 1 · stream 3 · stream 5 · stream 7 · … interleaved all governed by the parameters declared in the orange block The fingerprint surface is the setup the orange block carries. HTTP/1.1 has no equivalent block to point at. *HTTP/1.1 scatters a page across disposable sockets that declare nothing. HTTP/2 front-loads a per-connection configuration that then governs every multiplexed stream, read once and valid throughout.*

Contrast that with the HTTP/1.1 pool. There is no first round-trip that configures the rest, because there is no “rest” — each connection is independent and each request restarts from zero. Even if you could extract some quirk from one HTTP/1.1 socket, you would have to re-extract it from every other socket in the pool, and you would lose it the instant the browser closed that socket and opened a fresh one for the next navigation. The signal would be fragmented and ephemeral. Multiplexing makes it unified and durable, because the architecture demands a single persistent connection with declared state, and a persistent declared state is, definitionally, a fingerprint.

There is a subtlety the NGINX reference implementation of this technique calls out directly. Some of the frames involved, the PRIORITY frames especially, can be re-sent on a reused connection as the browser reprioritizes resources during a page load. A fingerprinting module that read priority frames at an arbitrary moment would get an unstable answer. So the implementation deliberately reads only the frames of the initial connection setup, so the fingerprint does not drift within a single connection. That detail tells you the whole design is organized around the connection’s opening moment, the one window in which the client’s configured-once state is cleanly observable.

What the server actually reads

Akamai’s threat research team, Ory Segal, Aharon Fridman, and Elad Shuster, published the canonical version of this technique as a 2017 whitepaper, presented at Black Hat Europe and AppSec USA that year. They measured more than 10 million HTTP/2 connections and pulled fingerprints for over 40,000 distinct user-agent strings, which is what established that the signal is stable and discriminating at scale rather than just in theory. The format they proposed concatenates four observations from the connection’s opening with pipe separators. Crawlex covers the format string itself in depth in a dedicated post on the Akamai HTTP/2 format; here the interest is in why each component became observable only once one connection carried everything.

The first component is the SETTINGS frame, rendered as semicolon-separated id:value pairs in the order the client sent them. Covered above. It exists because multiplexing forces the client to declare connection-wide limits before streams can run.

The second is the connection-level WINDOW_UPDATE. HTTP/2 has flow control, and the connection-level flow-control window starts at 65,535 octets per RFC 9113. A client that wants to receive data faster than that allows raises the window by sending a WINDOW_UPDATE on stream 0 immediately after its SETTINGS frame. The increment it chooses is a fingerprint: a measured Chrome sends 15663105, bringing its connection window up to roughly 15 MB; Firefox sends a different increment. A client that never bumps the window at all, which many minimal HTTP libraries do not, records a 0 here, and that 0 is itself a tell.

Flow control of this kind exists because of multiplexing. Many streams share one connection and one TCP congestion window, so HTTP/2 needs its own layer of windowing to keep one heavy download from starving every other stream on the pipe. HTTP/1.1 had no such mechanism because it had no shared pipe to arbitrate; one request owned its socket entirely. The WINDOW_UPDATE signal is a direct consequence of the architecture that the fingerprint exploits.

One observed Firefox connection, as a fingerprint string 1:65536;4:131072;5:16384 | 12517377 | 3:0:0:201 | m,p,a,s SETTINGS, as sent WINDOW_UPDATE PRIORITY pseudo-hdr order Each segment comes from a frame the client must emit at connection setup. Drop any segment to 0 and the absence is itself a distinguishing value. Example string per the nginx-http2-fingerprint reference implementation. Exact values drift between browser versions. *A real Firefox connection rendered in Akamai's four-part notation. Each pipe-separated field is read once, at the connection's opening, and holds for every stream that follows.*

The third component is the PRIORITY frames. The original HTTP/2 spec, RFC 7540, let a client build a dependency tree among streams, assigning each stream a parent, a weight from 1 to 256, and an exclusivity flag, so the server could schedule bandwidth across the multiplexed streams. Different stacks built these trees differently, and the tree was a strong fingerprint. Firefox in particular sent a characteristic arrangement; the example above shows a single priority entry, 3:0:0:201, meaning stream 3, not exclusive, depending on stream 0, weight 201. This signal, too, is multiplexing-native: there is nothing to prioritize when one request owns its connection, so HTTP/1.1 simply has no analog.

The fourth is pseudo-header order. HTTP/2 replaces the request line with four mandatory pseudo-headers, :method, :scheme, :authority, and :path. RFC 9113 requires they all appear before regular headers but does not mandate an order among the four. So clients order them however their HPACK encoder writes them, and the order is stack-specific. Chrome emits m,a,s,p; Firefox emits m,p,a,s; Safari emits m,s,p,a. Crawlex has a separate post on the pseudo-header ordering signal for readers who want only that piece. It is the one component that is not strictly a connection-level property, since pseudo-headers ride on each request, but the ordering is so consistently fixed per stack that it behaves like one.

The pseudo-header point connects to a quieter HTTP/2 change worth naming. HTTP/2 does not carry headers as text. It carries them through HPACK, a stateful compression scheme that keeps a dynamic table of previously-seen header fields per connection and refers back to them by index. Whether a client uses indexed references, the literal representation it picks for a never-before-seen field, whether it permits that field to be added to the dynamic table or marks it never-indexed, all of that is encoder behavior the stack chose and the receiver can observe. The HEADERS frame, in other words, is not just the ordered list of names a server reconstructs at the end; the byte-level encoding of how those names were compressed is itself stack-specific. This sits adjacent to the better-known header-order and casing signal that survives from HTTP/1.1, but HPACK gives it a connection-scoped dimension HTTP/1.1 never had, because the compression table is connection state that persists across every request on the pipe.

How RFC 9218 moved the priority signal off the connection

The PRIORITY frame story has a twist that matters for anyone reasoning about the current state of the surface. The RFC 7540 tree-based priority scheme was a mess in practice. It was complex to implement, inconsistently interpreted across servers, and several large deployments found it gave no measurable benefit over far simpler schemes. RFC 9113 deprecated it outright while keeping the frame parsing around for interoperability. In its place, RFC 9218, published June 2022 by Kazuho Oku of Fastly and Lucas Pardue of Cloudflare, defined a new, simpler prioritization scheme.

The new scheme does not live in a setup frame. It lives in a priority request header carrying two parameters: urgency, an integer from 0 to 7 with a default of 3, where lower is more urgent; and incremental, a boolean defaulting to false that says whether the response is useful as it streams in. Reprioritization happens through a small PRIORITY_UPDATE frame (type 0x10 in HTTP/2) rather than a dependency tree. Chrome moved to signaling priority this way and stopped sending the old tree-style PRIORITY frames during connection setup, which is why a modern Chrome records a 0 in the PRIORITY slot of the Akamai fingerprint.

Where the priority signal lives, before and after RFC 7540 (deprecated) PRIORITY frames at setup dependency tree, weights 1–256 → strong connection fingerprint RFC 9218 (2022) priority: u=3, i per-request header, urgency 0–7 → signal moves into request data Chrome now records 0 in the PRIORITY slot of the Akamai fingerprint and signals via the header instead. The fingerprint did not vanish — it shifted layer, and the header's presence and shape became the new tell. *The priority signal migrated from a connection-setup frame to a per-request header. The fingerprint did not disappear; it moved into the request data, where the header's exact value and presence form a new, parallel tell.*

This is a useful illustration of how the surface behaves under pressure. A signal that was firmly connection-level (the PRIORITY frame, observable once at setup) got replaced by a signal that rides each request (the priority header). The fingerprint did not go away. It moved layers. Now the presence or absence of the header, the exact u= value, whether i is set, and how those compare to what a claimed browser version would send are all part of the picture. The connection-level slot reads 0 for Chrome, and the discriminating information relocated into the header set, where it joins header order as an application-layer signal. The lesson generalizes: collapsing connections created the connection-level surface, but the individual signals on it are free to migrate up and down the stack as the protocol evolves.

Why this is hard to spoof, and where it is not

The reason connection-level HTTP/2 fingerprints became a load-bearing anti-bot signal is that they are awkward to fake from the usual tooling. The values come out of the networking stack, not the application. When you write a scraper in Python with the standard library, or in Go with net/http, the SETTINGS frame, the window increment, the pseudo-header order, all of that is produced by the HTTP/2 implementation you did not write and cannot easily reach. You can set any User-Agent string you want, but the connection underneath still announces itself as that library, and the mismatch between the claimed browser and the actual stack is the detection. This is the same structural problem that makes TLS fingerprinting effective: the lie is in the application layer, the truth is in the bytes below it, and the two do not match.

It is not unfakeable. Tools exist that re-implement a browser’s exact frame behavior so the connection matches the claimed User-Agent, and a determined operator can align the SETTINGS values, the window increment, the pseudo-header order, and the priority signaling to a target browser’s profile. But it raises the floor considerably. You can no longer slap a Chrome User-Agent on a default HTTP client and pass; you need a client whose entire h2 setup is a byte-accurate replica of the browser you are claiming, kept current as that browser’s defaults shift between versions. The fingerprint puts the cost of impersonation back where the architecture put it: in the connection.

The defensive value is the same property read from the other direction. A server does not have to challenge a client, run JavaScript, or wait for behavior. It reads the first round-trip of the connection, compares the h2 fingerprint to the User-Agent, and has a verdict before the application ever sees the request. Vendors that build detection on this, which Crawlex examines in posts on DataDome’s use of HTTP/2 signals and Cloudflare’s TLS and HTTP/2 fingerprinting, lean on it precisely because it is cheap, passive, and hard to forge from common tooling. None of that was available under HTTP/1.1, where the connection said nothing about the client.

HTTP/3 inherits the surface and adds to it

HTTP/3 keeps the multiplexing idea and moves it onto QUIC, which runs over UDP and folds the transport and cryptographic handshakes together. The fingerprint logic carries over wholesale, because the same architectural fact holds: one long-lived connection, configured up front by the client’s stack. HTTP/3 still has a SETTINGS frame, still has flow control, still has the four pseudo-headers and a per-connection header-compression scheme (QPACK in place of HPACK). A server can read the same kinds of stack-specific choices it read under HTTP/2.

QUIC then adds a layer of its own below that. Where HTTP/2’s connection setup is a SETTINGS frame riding on top of a TLS handshake, QUIC’s connection setup carries transport parameters, things like the initial flow-control limits (initial_max_data and the per-stream initial_max_stream_data values), the maximum number of concurrent streams, and idle timeout, negotiated as part of the QUIC handshake itself. Those parameters are chosen by the QUIC implementation, and they vary between stacks the same way SETTINGS values do. On top of that, QUIC exposes handshake-level behaviors with no HTTP/2 analog at all: whether the client attempts 0-RTT resumption, how it handles connection migration, the precise shape of its Initial packets. The surface did not shrink when the industry moved toward HTTP/3. It grew, because there are now two configured-once layers stacked on one connection instead of one. Crawlex’s posts on HTTP/3 and QUIC fingerprinting and the QUIC initial-packet surface go into that lower layer; the relevant point here is that the move to HTTP/3 confirmed the pattern rather than escaping it.

What multiplexing actually changed

Strip away the detail and the change is one sentence. HTTP/1.1 made the connection disposable and stateless, so there was nothing there to fingerprint; HTTP/2 made the connection singular, persistent, and self-describing, so the connection became the fingerprint. Every signal this post walked through, the SETTINGS values, the flow-control window increment, the priority tree, the pseudo-header order, exists because multiplexing required the client to configure a long-lived shared pipe up front and in the clear. The fingerprint is not a flaw in HTTP/2. It is the visible shadow of the design decision that made HTTP/2 fast.

That is also why the surface is durable even as individual signals move. The priority signal slid from a setup frame into a request header between RFC 7540 and RFC 9218, and the fingerprint absorbed the change without losing power, because the underlying fact has not changed: one connection, configured once by code the operator did not write, carrying everything. As long as that holds, the opening round-trip of an HTTP/2 connection will keep describing its client more honestly than any header the client chooses to send. The performance win and the fingerprint are the same object seen from two sides, and you do not get one without the other.


Sources & further reading

Further reading