Skip to content

The HTTP/2 pseudo-header ordering fingerprint

· 21 min read
Copyright: MIT
The four pseudo-headers :method :authority :scheme :path as a monospace stack with the order m,a,s,p highlighted in orange

Every HTTP/2 request opens with four short fields that no longer ride in a request line. The method, the authority, the scheme, and the path each become their own pseudo-header, each prefixed with a colon, each compressed into the HEADERS frame before any ordinary header. The specification is precise about what these four fields mean and where they sit relative to the rest. It says almost nothing about the order they take among themselves. That silence is the whole story. Chrome emits them in one fixed order, Firefox in another, Safari in a third, and a plain Go or Python client in an order that matches no browser at all. None of these orders is wrong. They are simply different, and the difference is stable enough to read as a label.

The question this post answers is narrow on purpose. Given a single HTTP/2 request, with the User-Agent stripped out, can you tell what actually generated it from the order of those four colons? The answer is usually yes, and the reason is that the order is baked into source code, not chosen per request. A client that claims to be Chrome but emits :method :path :authority :scheme has contradicted its own User-Agent at the protocol layer, on the very first frame, before a single byte of page content moves. This is a one-signal deep dive into that contradiction: where the four fields come from, why the order is free, what each major implementation picked, how the Akamai fingerprint format encodes it, and why it is one of the hardest fingerprints to fake from a generic HTTP stack.

The sections below run in order. First the four pseudo-headers themselves and the RFC text that pins their position but not their sequence. Then where the order actually gets decided, in the data structures that emit the HEADERS frame. Then the per-client table, browser by browser and library by library, grounded in the original Akamai measurement and the current Chromium source. Then how the order folds into the Akamai HTTP/2 fingerprint string and the JA4H-style hashes. Then the detection logic a WAF runs against it, and finally what the signal can and cannot prove in 2026.

The four fields and the RFC’s deliberate silence

HTTP/1.1 carried the method, target, and version in a single ASCII start-line: GET /index.html HTTP/1.1. HTTP/2 deletes that line. The information it carried is split into pseudo-header fields, which RFC 9113 defines as fields whose names begin with a colon. They are not header fields in the ordinary sense. The RFC is blunt about it: endpoints must not generate pseudo-header fields other than those defined in the document, and pseudo-headers are not HTTP header fields. They are control data that happens to travel in the same HPACK-compressed field block.

For a request, four are defined. The :method field carries the HTTP method. The :scheme field carries the scheme portion of the target URI, usually https, though the RFC notes it is not restricted to http and https. The :authority field carries the authority, the host and optional port, replacing the Host header of HTTP/1.1. The :path field carries the path and query. Together they reconstruct everything the old start-line used to say.

RFC 9113 gives exactly one ordering rule, and it is about position relative to ordinary fields, not order among the four. The text in Section 8.3 reads:

All pseudo-header fields MUST appear in a field block before all regular field lines.

That is the entire constraint. Pseudo-headers come first, regular headers come after, and a receiver that sees a regular field followed by a pseudo-header must treat the block as malformed. Within the pseudo-header group, the RFC never says :method precedes :scheme or anything of the kind. The four fields can be emitted in any of the twenty-four permutations and the request is equally valid. A conformant HTTP/2 server has to accept all of them.

One HEADERS field block, left to right on the wire pseudo-headers (must come first) :method :authority :scheme :path regular headers user-agent accept-encoding The RFC fixes the boundary at the orange line. It says nothing about the order of the four boxes inside it. 24 permutations are all spec-valid. Each client picks one and keeps it. *The only ordering rule in RFC 9113 is the boundary: pseudo-headers before regular fields. The internal order of the four is implementation's choice, and that choice is what fingerprints.*

This is the structural reason the fingerprint exists. When a specification leaves a degree of freedom open, independent implementers fill it independently, and they fill it with whatever their internal data structure happens to produce. Nobody coordinated. The HTTP/2 working group even anticipated that protocol nuances would leak client identity. RFC 9113’s security section notes that the manner in which settings and flow-control windows are managed, and the way priorities are assigned, can be used to passively fingerprint clients over time. Pseudo-header order is the same kind of leak, sitting one frame earlier.

Where the order actually gets decided

The order is not a runtime decision. It is a property of the code that builds the HEADERS frame, usually the insertion order into an ordered map that preserves whatever sequence the developer wrote. The Akamai researchers found this directly by reading Chromium. In the network stack, a function called CreateSpdyHeadersFromHttpRequest assembles the pseudo-headers, and it does so in a literal, top-to-bottom sequence: method first, then authority, then scheme, then path. The container that holds them, historically SpdyHeaderBlock, carries a comment in the source noting that it preserves insertion order, and it was backed by a structure that keeps elements in the order they were added rather than sorting them.

The current Chromium source still does the same thing. In net/spdy/spdy_http_utils.cc, the request-building path inserts :method first, then :authority, then :scheme, then :path, using the symbolic constants kHttp2MethodHeader, kHttp2AuthorityHeader, kHttp2SchemeHeader, and kHttp2PathHeader. The order is hardcoded in the sequence of insert calls. It has not drifted across the Chrome versions that ship today, which is exactly why detection vendors trust it. The HTTP/2 layer changes far less often than TLS does, where Chrome has reshuffled extension ordering and added GREASE values that broke older JA3 hashes. The pseudo-header order is sticky in a way the TLS ClientHello is not.

Because the order is structural, it is also uniform across a given build. Two requests from the same Chrome process will carry the same pseudo-header order every time, on every stream, for every host. There is no jitter to average over and no entropy to randomize. This is the opposite of, say, a timestamp or a nonce. The defender does not need a population sample to read it. A single request settles the question. That property is what makes pseudo-header order a clean, deterministic tell rather than a probabilistic one.

It also means the order is set well before the bytes hit the wire, at the point where the request object is translated into a frame. HPACK, the header-compression layer HTTP/2 uses, does not reorder anything. It compresses fields in the sequence it is handed them, mapping common names to static-table indices and emitting the rest as literals. So whatever order the request-encoder chose is exactly the order that travels, after compression, in the HEADERS frame. A receiver decodes the HPACK block back into a field list in that same sequence. Nothing in the path between the encoder’s insert calls and the server’s decoded list shuffles the four pseudo-headers, which is why a structural choice in one codebase surfaces verbatim as an observable on the wire. There is no normalization step that a defender has to undo and no compression artifact that obscures the order. What the encoder wrote is what the parser reads.

One subtlety is worth flagging for anyone measuring this themselves. The HTTP/2 spec requires the four pseudo-headers to sit before regular fields, and it requires a receiver to reject a block where a pseudo-header follows a regular one, but it imposes no penalty for any particular order among the four. That means a permissive server will happily accept all twenty-four permutations and route the request correctly, so the order is a free observable rather than something the protocol enforces. A defender reading it is reading a choice the client made for itself, not a constraint the protocol imposed, which is exactly what makes it identifying.

What each client picked

The original Akamai whitepaper, published in 2017 by Ory Segal, Aharon Fridman, and Elad Shuster, measured this across more than ten million HTTP/2 connections from Akamai’s edge and tabulated the orders for common clients. The values they recorded have held up. A modern measurement of Chrome, Firefox, and Safari produces the same three orders, which is itself worth noting: nine years on, the browsers have not changed their minds.

Using the single-letter encoding the whitepaper introduced, where m is :method, a is :authority, s is :scheme, and p is :path, the picture looks like this. Chrome sends m, a, s, p. Firefox sends m, p, a, s. Safari sends m, s, p, a. All three start with the method, which is the one point of agreement, and then they diverge completely. curl, in the version Akamai tested, sent m, p, s, a, different again. The Go standard library’s HTTP/2 client sent a, m, p, s, leading with authority rather than method. Jetty’s HTTP/2 client sent s, m, a, p, leading with scheme. Those last two are the interesting ones for detection, because they do not even keep the method first, which every browser does.

Pseudo-header order by client (m=:method a=:authority s=:scheme p=:path) Chrome m a s p Firefox m p a s Safari m s p a curl 7.54 m p s a Go net/http2 a m p s Jetty 9.3 s m a p method not first scheme leads *Orders as recorded by Akamai's 2017 measurement, in their single-letter encoding. The three browsers all start with the method; the two libraries highlighted in orange do not, which alone separates them from any browser.*

A few cautions about reading this table. The Go and Jetty orders date from the 2017 measurement, and library internals can change between releases in a way browser engines rarely do, so a current Go HTTP/2 client may differ from the a, m, p, s recorded then. The browser orders are the durable part. Anyone building detection on this should re-measure libraries against the versions they actually see in traffic rather than trusting a nine-year-old row. The point the table makes is structural and holds regardless of the exact library values: browsers cluster on method-first orders that are stable per engine, and generic HTTP stacks scatter across the permutation space, frequently in ways no browser produces.

There is a deeper reason the browsers are so consistent. The order is downstream of how each engine models a request internally. Chromium’s request object exposes method, host, scheme, and path as distinct accessors, and the SPDY/HTTP2 conversion reads them in a sequence the author wrote once and never had reason to touch. Firefox’s necko stack builds the field set in a different sequence for its own historical reasons. Safari’s networking sits on Apple’s CFNetwork, which produces yet another. The orders are fossils of three independent codebases, and fossils do not move. For the cross-layer picture of how this sits alongside the TLS handshake that precedes it, the ClientHello catalog covers the matching TLS side of each of these engines.

Folding the order into a fingerprint string

A single signal is more useful when it is combined with its neighbors into one comparable token. The Akamai whitepaper proposed exactly such a token for HTTP/2, and it has become the de facto format the industry calls the Akamai HTTP/2 fingerprint. The structure is four fields joined by pipes:

S[;] | WU | P[,]# | PS[,]

The first field, S, lists the SETTINGS parameters the client sent at connection start as key:value pairs joined by semicolons, in the order they appeared. The second, WU, is the WINDOW_UPDATE increment, or 00 when no such frame is present. The third, P, encodes the PRIORITY frames as streamID:exclusivity:dependency:weight tuples joined by commas, or 0 when absent. The fourth field, PS, is the pseudo-header order, written as the single letters m, a, s, p separated by commas. The whitepaper gives a worked Firefox example:

1:65536;4:131072;5:16384|12517377|3:0:0:201,5:0:0:101,7:0:0:1,9:0:7:1,11:0:3:1|m,p,a,s

The trailing m,p,a,s is the pseudo-header order, the subject of this post, sitting as the last of the four fields. A modern Chrome request produces a string whose final field reads m,a,s,p, the Chrome order, after a SETTINGS and window-update section that also matches Chrome. The whole token is what detection vendors and tools like BrowserLeaks and Scrapfly’s HTTP/2 fingerprint surface display when you hit them with a real browser. The pseudo-header field is the part of that token least likely to match between a browser and an impersonating client, because the other three fields can be configured at the protocol-tuning layer while the pseudo-header order is buried in the request-encoding path.

The Akamai HTTP/2 fingerprint, four pipe-separated fields 1:65536;4:... SETTINGS 12517377 WINDOW_UPDATE 3:0:0:201,5:0:0:101,... PRIORITY m,p,a,s pseudo-header order The first three fields can be tuned at the protocol layer. The fourth lives in the request-encoder, where generic stacks rarely let you reach it. *The pseudo-header order is the last field of the Akamai token. The example shown is the whitepaper's Firefox fingerprint; the m,p,a,s tail is the Firefox order.*

The JA4 suite encodes HTTP differently. JA4H, the HTTP component of the JA4+ family, hashes header order, the cookie and referer presence, the language, and method into its own token, and it deliberately works at the HTTP semantic layer so it can apply to both HTTP/1.1 and HTTP/2. JA4H does not encode pseudo-header order as a separate field the way the Akamai format does, because it aims for a transport-agnostic view of the request. The two approaches are complementary. The Akamai format is the one that isolates pseudo-header order as a named component, and it is the format detection vendors reach for when they want the HTTP/2-specific tell. For the full SETTINGS-and-window side of that token, the Akamai HTTP/2 fingerprint format post covers the first three fields in the depth this post gives the fourth.

The detection logic is almost free

What makes pseudo-header order attractive to a defender is not just that it discriminates. It is that reading it costs nothing. The four pseudo-headers arrive in the first HEADERS frame of the first request on the connection. A server’s HTTP/2 parser already decodes them to route the request. Capturing the order they arrived in is a matter of recording four enum values as HPACK emits them, before any application logic runs. There is no JavaScript challenge, no round trip, no client-side probe. The signal is present on the very first request and it is present whether or not the client ever executes a line of script.

The cross-check that follows is a static comparison. The client’s User-Agent claims an engine. That engine has a known pseudo-header order. If the observed order does not match the claimed engine, the request has contradicted itself. A request whose User-Agent says Chrome and whose pseudo-header order reads a, m, p, s is not a Chrome request, full stop, regardless of how perfect its headers otherwise look. The contradiction is the signal. It is the same shape of tell as a TLS ClientHello that claims one browser while the HTTP/2 SETTINGS frame claims another, and it sits in the same family as the HTTP/1.1 header order and casing tell that catches clients one protocol version down.

User-Agent claims Chrome observed order a,m,p,s order == expected(UA)? no: contradiction All four values are already decoded by the server's HTTP/2 parser. The check is a string compare. *The detection is a static comparison between the engine the User-Agent claims and the engine the pseudo-header order implies. A mismatch is self-contradiction on the first frame.*

There is a second-order check that costs almost as little. A detector does not only compare the order against the User-Agent; it compares the order against the rest of the protocol fingerprint the same connection produced. The pseudo-header order, the SETTINGS frame, the window-update size, and the TLS ClientHello are four observables from four different layers of the same client, and a genuine browser produces a self-consistent set across all of them. An impersonation that fixes one layer and leaves another at its library default produces a set that does not cohere. A request whose TLS handshake looks like Chrome, whose SETTINGS frame looks like Chrome, but whose pseudo-header order reads a,m,p,s is not internally consistent, and inconsistency across layers is harder to fake than any single layer in isolation. The pseudo-header order is one row in that consistency matrix, and its value is that it is one of the rows most clients get wrong.

The signal’s weakness is the same as its strength: low entropy. Pseudo-header order alone places a request into one of a handful of buckets, not into a unique identity. It cannot track a user. Three browser orders and a scatter of library orders is the entire alphabet. So no serious detector uses it alone. It is one input to a consistency model that also weighs the TLS fingerprint, the SETTINGS frame, the header order, and the IP reputation. Its value in that model is that it is cheap, early, and very hard to spoof from the wrong stack, which makes it an efficient first filter. A request that fails the pseudo-header check can be dropped or challenged before the detector spends cycles on anything more expensive. The full picture of how a vendor combines this with the rest of the HTTP/2 surface is in how DataDome uses HTTP/2 and network fingerprints.

Why it is hard to fake from a generic stack

A scraper author who learns about this signal will want to set the order to match the browser being impersonated. The obstacle is that most HTTP libraries do not expose the pseudo-header order as a knob. They build the HEADERS frame from their own internal field structure, in their own fixed sequence, the same way Chromium does. The Go HTTP/2 transport, Python’s httpx over its h2 backend, Rust’s hyper, and the rest each emit pseudo-headers in whatever order their encoder was written to produce. There is no public configuration parameter that says “send authority before method,” because the spec never asked anyone to make that configurable. The order is an implementation detail that nobody designed to be tunable.

This is a different problem from spoofing a regular header. Setting User-Agent: Mozilla/... is a one-line call that every HTTP client supports, because regular headers are a key-value map the caller owns. The pseudo-headers are not in that map. They are synthesized by the library from the request’s method, URL host, URL scheme, and URL path, each pulled from a different accessor on the request object, in a sequence the library hardcodes. There is no place to put a “pseudo-header order” string because the library never modeled the four as a reorderable list in the first place. They are derived values, assembled on the way out, and the assembly order is not part of the public interface. That gap between what the API exposes and what the wire reveals is the entire reason the signal survives.

To change it, you have to reach into the frame-encoding layer itself, which means either patching the library or using one built specifically to mirror a browser. The tools that do this, the curl-impersonate family and the various browser-mimicking Go HTTP clients, do not merely set a header; they reimplement the HEADERS-frame construction so the pseudo-headers come out in the target browser’s order along with the matching SETTINGS frame and TLS handshake. That is real engineering, not a config flag, and it is the reason pseudo-header order remains discriminating in 2026 even though it has been documented since 2017. The knowledge is public. The ability to act on it from an arbitrary stack is not free. This is the same dynamic that keeps detecting curl-impersonate and uTLS an active arms race rather than a solved problem.

The mechanism, in pseudocode, is worth stating plainly because it shows why patching is the only route. A naive client builds its request like this:

frame = new HeadersFrame()
for field in request.pseudo_fields(): # order fixed by the library
frame.append(field.name, field.value)
for header in request.headers():
frame.append(header.name, header.value)

The order of request.pseudo_fields() is decided by the library’s source, not by the caller. There is no argument to reorder it. To impersonate a browser you must replace that iteration with one that emits the four fields in the target order, which means forking the encoder. A working impersonation toolkit does exactly that and keeps the SETTINGS and priority fields in sync as well, because a correct pseudo-header order on top of a wrong SETTINGS frame just trades one contradiction for another. Describing the mechanism is fine; shipping a turnkey forked encoder tuned to the current Chrome build is the part that stays out of a defensive reference like this one.

What the order proves, and what it does not, in 2026

Pseudo-header order is a small signal with an outsized reliability. It is one of four short fields at the very front of an HTTP/2 request, it carries only a few bits of entropy, and it cannot identify a person. What it can do is answer one question crisply: does this request come from the engine its User-Agent claims? For the three major browsers and most generic HTTP libraries the answer is unambiguous, because each emits a fixed order baked into source code that has not moved in years. Chrome is m,a,s,p, Firefox is m,p,a,s, Safari is m,s,p,a, and a request that claims one of those but produces a different order has told on itself before its first byte of payload.

The signal has aged unusually well. The Akamai whitepaper documented it in 2017, the orders it measured for browsers still hold, and the Chromium source still inserts the four fields in the same sequence the paper quoted. That stability is the point. A fingerprint is only useful if it is consistent, and pseudo-header order is consistent precisely because it is an accident of code structure that no engine team has any reason to disturb. The cost of changing it would be nonzero and the benefit would be nil, so it sits frozen, a small fossil at the head of every request, readable for free by anyone parsing the frame. The defenders who rely on it are not betting on a clever heuristic. They are betting that three large codebases will keep emitting four fields in the order they always have, and so far that has been one of the safest bets in fingerprinting.


Sources & further reading

Further reading