Skip to content

Header order and casing as a fingerprint: the forgotten HTTP/1.1 tell

· 22 min read
Copyright: MIT
A stack of HTTP request headers in monospace, one labelled User-Agent rendered in mixed case and highlighted in orange, the rest in grey

Strip the User-Agent off an HTTP request and you have not made it anonymous. The order the headers arrive in still says who sent them. A real Chrome request puts its sec-ch-ua block right after the connection-control headers and drops Accept-Encoding near the bottom; Firefox interleaves the same fields in a visibly different sequence; a default Python requests call sends four headers in an order no browser has ever produced. None of these orderings is wrong. The specification permits all of them. That permission is exactly what makes the order legible, because each client picked one sequence, wrote it into its source code, and has emitted it on every request since.

The casing of the field names tells a parallel story. Over HTTP/1.1 the names ride the wire in whatever case the client chose, and browsers choose User-Agent, not user-agent or USER-AGENT. A server is forbidden from caring about that case for parsing, but nothing stops it from noticing. The question this post answers is narrow on purpose: given a single HTTP/1.1 request with the obvious identity headers removed, how much can you still recover from the order of the fields and the case of their names, and what happened to that signal when HTTP/2 made every name lowercase and reshuffled the rules?

The sections below run in sequence. First the RFC text that makes header order free and casing irrelevant to parsing, which is the loophole the whole technique lives in. Then how a browser actually decides its order, and why that order is stable across requests. Then the per-client table, browsers against libraries, grounded in a 2008-era patent that already described the method and in current captures. Then casing as a second axis. Then the HTTP/2 reset: mandatory lowercasing, what it erased, and what survived as pseudo-header order. Finally the detection logic a WAF runs, and what the signal can and cannot prove in 2026.

The loophole: order is free, case does not matter for parsing

HTTP has always treated a header section as a set of fields, not a sequence. RFC 9110, the 2022 core-semantics document, is explicit. Section 5.1 states that “field names are case-insensitive.” Section 5.3 says that “the order in which field lines with differing field names are received in a section is not significant.” A server that behaves correctly must treat User-Agent: x followed by Accept: y identically to the same two lines swapped, and must treat User-Agent, user-agent, and USER-AGENT as the same field name.

There is one carve-out, and it matters for a different reason than fingerprinting. RFC 9110 immediately adds that “the order in which field lines with the same name are received is therefore significant to the interpretation of the field value; a proxy MUST NOT change the order of these field line values when forwarding a message.” So repeated fields with one name (multiple Set-Cookie, multiple Via) have a meaningful internal order. Fields with different names do not. The spec even nudges senders toward a convention: it is “good practice to send header fields that contain additional control data first, such as Host on requests.” Good practice, not a requirement. Browsers follow it. Naive libraries sometimes do not.

This is the loophole in one sentence. The protocol guarantees that order and case never change meaning, which means no client is ever forced to normalize them, which means every client is free to leak a stable, idiosyncratic order and casing that the receiver is equally free to record. Correct parsing and fingerprinting are not in tension. A WAF can honor every line of RFC 9110 in how it interprets the request and still hash the raw byte order of the field block for identity. The standard governs semantics; the fingerprint reads the syntax the standard left unconstrained.

It is worth being precise about why the spec writers left it that way, because the looseness was deliberate and old. HTTP grew up on the assumption that a message is a bag of named fields, an idea inherited from email headers in RFC 822 and its successors, where the same set-not-sequence model applies. Forcing a canonical order would have meant defining one, maintaining it as new fields were registered, and breaking every existing implementation that did not follow it. Forcing a canonical case would have meant the same. The cheaper choice, and the correct one for interoperability, was to declare order and case semantically irrelevant and let implementations do whatever they liked. Interoperability and fingerprintability turn out to be two faces of the same decision: a format loose enough that anyone can speak it is also a format loose enough that everyone speaks it slightly differently, and those differences are stable because nobody has any reason to change them. The spec optimized for “any client can talk to any server,” and got “every client is identifiable” for free.

The same request, read two ways Semantic layer (RFC 9110) User-Agent: ... Accept: ... Accept-Encoding: ... order ignored, case folded = a set of fields Fingerprint layer User-Agent Accept Accept-Encoding exact sequence + casing = a stable signature *The protocol throws away order and case when interpreting a request; a fingerprinting layer hashes the same bytes before they are normalized.*

Where the order actually comes from

A browser does not assemble its header list fresh on every navigation. The order is a property of the code path that builds the request, and that code path is fixed at compile time. In a Chromium-based browser the network stack constructs the request headers in a defined sequence: connection-management headers, then the client-hint and identity block, then the content-negotiation headers. The sequence is the same on every request of the same type because it comes from the same function walking the same fields in the same order. Change the URL, change the cookies, change nothing about the order. That stability is what turns it from noise into a signature.

The order is not even fully uniform within one browser. It shifts by request type. A top-level navigation carries Upgrade-Insecure-Requests and the full Sec-Fetch set; an XHR or fetch from script carries a different subset and can place them differently; a preflight OPTIONS looks different again. So the practical fingerprint is not one string per browser but a small family of strings keyed on request context. A detector that compares your order against the browser you claim to be has to compare against the right variant for the request type, which is one reason naive order-matching produces false positives and good detectors are stricter about context.

The deeper point is that the order encodes a build, not a configuration. A user cannot reorder Chrome’s request headers from the settings UI. An extension can rewrite headers through the declarativeNetRequest or webRequest APIs, but doing so cleanly without disturbing the order is awkward, and most do not bother. So the order that arrives at the server reflects the engine that produced it far more reliably than the User-Agent string does, because the User-Agent is a single string the client can set to anything while the order is an emergent property of thousands of lines of networking code. This is the same logic that makes the HTTP/2 pseudo-header order and the TLS ClientHello such durable tells: they come from the build, not from a field the operator types in.

The stability has a flip side that detectors have to account for: it drifts slowly across versions. When Chromium ships a new request header, it slots into the sequence at a fixed position decided by where the engineers added the code, and that position becomes the new normal for every build from then on. The client-hint headers are the clearest example. Before User-Agent Client Hints existed, Chrome’s order had no sec-ch-ua block at all; once the feature shipped, the block appeared near the top of the navigation request and has stayed there. So a header-order profile is implicitly version-scoped, and a detector maintaining these profiles is really maintaining a moving target, re-capturing the canonical order for each new browser release. A client impersonating an old Chrome order while sending a current Chrome User-Agent is as inconsistent as one that sends the wrong order entirely, just more subtly. The order is stable enough to be a fingerprint but not frozen, and keeping the reference profiles current is part of the ongoing cost of running this detection rather than a one-time table you write down and forget.

There is also the question of which fields are even present, which travels alongside order and is harder to fake than either. A browser navigation carries a specific set: the client-hint trio on Chromium, the Sec-Fetch metadata family, Upgrade-Insecure-Requests, a long structured Accept. A library carries a thin set and nothing it was not told to send. Presence is in some ways a stronger signal than order, because adding the missing headers in the right order requires knowing the full browser set cold, while order alone can be copied from a single capture. An operator who copies a Chrome header dump gets the set and the order together; an operator who hand-writes a few headers gets neither right. The two signals reinforce each other, and a detector reads them as one combined shape rather than two independent checks.

The per-client table

The idea that header order identifies a client is old. US Patent 8,694,608, filed in 2012 with a priority date of July 21, 2008, originally assigned to AOL and later held by Verizon, describes exactly this. Inventors William Salusky and Mark Ellzey Thomas spelled out a scheme that builds two values from a request: a CHSScore, a numeric sum of powers of two over which headers are present, and a CHSOrder, a character string encoding the sequence the headers arrived in. The patent gives worked examples. A Firefox request of the era ordered its headers Host, User-Agent, Accept, Accept-Language, Accept-Encoding, Accept-Charset, Keep-Alive, Connection, Referer, Cookie, yielding (in the patent’s example alphabet) CHSScore = 6078 and CHSOrder = "aibdcelghj". An Internet Explorer request of the same era ordered them Accept, Referer, Accept-Language, Accept-Encoding, User-Agent, Host, Connection, Cookie, yielding CHSScore = 1950 and CHSOrder = "bhdciagj". Same page, same content, two different fingerprints. The abstract states the method plainly: “a fingerprint is constructed based on the order and identity of HTTP headers included in a request from a client application and device.”

That 2008 IE order is a museum piece now. IE put Accept first and User-Agent deep in the middle. Modern browsers do not. But the technique aged perfectly, because the thing it measures, a fixed per-build sequence, is exactly as present in Chrome 2026 as it was in IE 2008. Only the specific strings changed.

Here is what current top-level navigations look like, reconstructed from packet captures and documented in scraping references. A Chrome request on Windows orders its headers roughly: Host, Connection, Cache-Control, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, Upgrade-Insecure-Requests, User-Agent, Accept, Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-User, Sec-Fetch-Dest, Accept-Encoding, Accept-Language. The client-hint trio sits high, the identity and content-negotiation headers follow, and Accept-Encoding lands near the end just before Accept-Language.

Firefox sends a different arrangement of an overlapping set: Host, User-Agent, Accept, Accept-Language, Accept-Encoding, Connection, Upgrade-Insecure-Requests, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site, Sec-Fetch-User. Note the immediate divergence: Firefox puts User-Agent second and Accept-Encoding fifth, near the top, where Chrome puts it second from last. Firefox also does not send the sec-ch-ua client-hint headers at all, because the User-Agent Client Hints mechanism is a Chromium feature, which is a presence-or-absence signal on top of the ordering signal.

Header order, top-level navigation, by client Chrome / Win Firefox / Win python-requests Host Connection Cache-Control sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform Upgrade-Insecure-Req User-Agent Accept Sec-Fetch-* Accept-Encoding Accept-Language Host User-Agent Accept Accept-Language Accept-Encoding Connection Upgrade-Insecure-Req Sec-Fetch-Dest Sec-Fetch-Mode Sec-Fetch-Site Sec-Fetch-User no sec-ch-ua block User-Agent Accept-Encoding Accept Connection four headers, no sec-ch-ua, no Sec-Fetch, order matches no browser Accept-Encoding (orange) lands near the bottom in Chrome, near the top in Firefox, second in requests. Sequences reconstructed from captures; exact tail order varies by version and platform. *Three clients, three orders. The position of Accept-Encoding alone separates all three. Python's default set also lacks the client-hint and Sec-Fetch families entirely.*

Then there are the libraries, which betray themselves twice: by sending the wrong order and by sending the wrong set. A default Python requests call sends a famously minimal header block, roughly User-Agent: python-requests/2.x, Accept-Encoding: gzip, deflate, Accept: */*, Connection: keep-alive. That Accept: */* is itself a tell, since browsers send a long, specific Accept string. The order is fixed by requests’s default-headers OrderedDict, and the awkward part is that requests does not reliably preserve a custom order you pass in, because the defaults and your additions get merged in a way that can reassert the library’s sequence. So an operator who carefully hand-writes a Chrome-shaped header dict can still ship it in requests’s order. The fix people reach for is not a header dict at all but an impersonation library, curl_cffi or curl-impersonate, which rebuilds the order and the set from a captured browser profile rather than letting the standard library impose its own.

curl itself, Go’s net/http, Node’s undici, Java’s HttpClient: each has its own default order and its own default header set, and none of them matches a browser out of the box. The reason these defaults are so consistent is mundane and total. The library author wrote one code path that builds the request, and every program using that library inherits it unchanged. Millions of curl invocations across the internet send the same handful of headers in the same order, because they are all running the same curl_easy_perform against the same option struct. That homogeneity is what makes the library fingerprint cheap to maintain: there is exactly one Chrome 130 order to record and exactly one default-requests order to record, and neither moves unless the upstream project changes it. The fingerprint database is small because the population of distinct senders is small, dominated by a few dozen widely deployed stacks. Go’s net/http is a useful example because it does something the others mostly do not, which moves us to the second axis.

Casing as a second axis

Go’s net/http canonicalizes header names. When you set a header it runs through textproto.CanonicalMIMEHeaderKey, which forces the first letter of each dash-separated token to uppercase and the rest to lowercase: user-agent becomes User-Agent, sec-ch-ua becomes Sec-Ch-Ua. That last transformation is the tell. A real Chrome emits sec-ch-ua in lowercase. Go’s canonicalizer turns it into Sec-Ch-Ua. So a Go client that copies Chrome’s header set verbatim and ships it over HTTP/1.1 will, by default, send the client-hint names in a casing no browser uses, and a detector that records raw casing sees the mismatch immediately. Bypassing this means reaching past the high-level API to write the header keys directly into the connection’s header map, which is exactly the kind of non-obvious surgery that separates a serious impersonation library from a header dict.

This is the second axis the title promises, and it only exists over HTTP/1.1. The names travel the wire in whatever case the sender wrote them, the receiver is forbidden from caring for parsing, and so the case becomes a free identity bit. Browsers settled on a consistent Title-Case for most names but lowercase for the newer sec-ch-ua and sec-fetch-* families, because those were specified and shipped in lowercase. A client that title-cases everything, or lowercases everything, or alphabetizes, has stamped its own toolkit on the request. Python requests title-cases through its CaseInsensitiveDict. Some raw-socket scripts send all-lowercase. Each is internally consistent and each differs from a browser.

The casing axis also covers the request line and the structure around it, not just field names. The exact spelling of the Connection value (keep-alive versus Keep-Alive), the presence or absence of a space after the colon, the use of \r\n versus bare \n line endings: all of these are technically free, all are constrained by good parsers to be tolerated, and all are stable per client. They rarely make it into a public fingerprint string because they are fiddly to capture, but a WAF terminating raw HTTP/1.1 can see every byte of them. The general principle holds. Anywhere the protocol tolerates variation, a client’s choice among the tolerated forms is a fingerprint, and the tolerance is what guarantees the client never had to hide it.

Same field name, three casings, over HTTP/1.1 Chrome sec-ch-ua lowercase, as shipped py requests Sec-Ch-Ua CaseInsensitiveDict title-cases Go net/http Sec-Ch-Ua CanonicalMIMEHeaderKey A receiver must treat all three as equal for parsing. It is free to record which one arrived. *The client-hint name ships lowercase from a real browser. Two common libraries title-case it by default, producing a casing no browser sends.*

The HTTP/2 reset: lowercasing erases half the signal

Then HTTP/2 changed the rules under everyone’s feet. RFC 9113, the 2022 HTTP/2 specification that replaced RFC 7540, mandates lowercase field names. The requirement is blunt: field names must be converted to lowercase when constructing an HTTP/2 message, and a request or response containing uppercase field-name characters must be treated as malformed. A peer that receives User-Agent instead of user-agent in an HTTP/2 HEADERS frame is entitled to reset the stream. The casing axis, the entire second half of the HTTP/1.1 fingerprint, is gone. Over HTTP/2 every compliant client sends user-agent, sec-ch-ua, accept-encoding, all lowercase, because the protocol forces it and HPACK’s static table is defined in lowercase anyway. There is no casing left to read because there is no casing variation left to produce.

The lowercasing was not an anti-fingerprinting measure. It came from HPACK, the header-compression scheme HTTP/2 uses, whose static table of common headers is defined entirely in lowercase, so lowercasing names lets them compress against that table. The fingerprinting consequence was a side effect. But the effect is real: an HTTP/1.1 client that leaked its toolkit through Sec-Ch-Ua versus sec-ch-ua leaks nothing through casing once it speaks HTTP/2, because the transport normalized the bit away. This is one of the rare cases where a protocol change genuinely shrank the fingerprint surface rather than moving it.

Order, though, survived. RFC 9113 lowercases names but does not sort them. The HEADERS frame carries the field block in the order the sender emitted it, and HPACK preserves that order. So the ordering signal that the 2008 patent described carries straight across from HTTP/1.1 to HTTP/2, minus the casing, plus a new sibling: the order of the four pseudo-headers, :method, :scheme, :authority, :path, which HTTP/1.1 did not have because it carried that information in a single start-line. The pseudo-header order is itself a clean per-client tell, deep enough to deserve its own treatment, and it folds together with ordinary-header order and the SETTINGS frame into the Akamai HTTP/2 fingerprint format. The net effect: moving to HTTP/2 cost a client the casing tell and gained it the pseudo-header tell, while the ordinary-header order tell persisted through both.

What survived the move to HTTP/2 HTTP/1.1 HTTP/2 (RFC 9113) header order header order name casing lowercased, signal erased pseudo-header order new tell, did not exist before *Header order carries through both protocols. Casing dies at the HTTP/2 boundary, mandatory-lowercased by HPACK. Pseudo-header order is a fresh signal HTTP/1.1 never had.*

What a detector does with it

A WAF or bot-management product reads this signal cheaply, which is why it is everywhere. It does not need JavaScript, a challenge, a cookie, or a round trip. The first request already carries the order and, over HTTP/1.1, the casing. The detector extracts the sequence of field names, often hashes it, and compares against a stored profile for the client the request claims to be. Vendors build this into composite fingerprints: the JA4 suite’s JA4H component hashes HTTP request headers including their order, and the broader server-side capture of the request joins it to the TLS handshake fingerprint so that a single connection yields a consistent or contradictory cross-layer story.

The power of the signal is in the contradiction, not the order alone. An order that matches no known client is weakly suspicious; plenty of obscure-but-legitimate clients exist. An order that actively contradicts the request’s own User-Agent is strongly suspicious, because it means the client lied in a field it controls (the UA string) while telling the truth in a field it did not think to control (the order). A request whose User-Agent says Chrome 130 but whose header order is Firefox’s, or whose Accept-Encoding sits where requests puts it, has failed an internal consistency check that no amount of UA spoofing repairs. The same request might pass TLS fingerprinting if the operator used an impersonation library for the handshake but a plain HTTP client for the request, which is a classic mismatch: browser-grade ClientHello, library-grade header order, on the same socket.

Detectors weight the signal carefully because order is noisier than it looks. Corporate proxies, TLS-terminating CDNs, and middleboxes can reorder or rewrite headers in transit, so a perfectly legitimate browser behind an enterprise proxy can arrive with a mangled order. Some HTTP/2 server stacks and intermediaries re-emit headers in their own order. So a strict order-equality check produces false positives, and mature products treat order as one weighted feature among many rather than a hard block. The signal is most decisive when it agrees with or contradicts a stack of other signals at once, which is the general pattern in this field: no single tell convicts, but a coherent set does, and an incoherent set does the opposite.

The pseudocode a detector runs is unglamorous, which is the point. Take the request. Read the User-Agent and resolve it to a claimed client and version. Look up the reference header order and header set for that client-version-and-request-type. Compare the observed order and set against the reference, scoring an exact match as clean, a near match as ambiguous, and a contradiction (a Firefox order under a Chrome UA, or a library order under any browser UA) as a strong negative. Over HTTP/1.1, fold in the casing check: do the names arrive in the casing that client uses, or in some library’s canonical form. Then hand the score to the wider model that also holds the TLS verdict, the IP reputation, and whatever behavioral signals exist. The header check by itself never decides anything. Its value is that it is free, it runs on the first byte, and it is one of the few signals that catches the specific failure mode of a spoofed identity, a request that says one thing in the field the operator controls and another in the field the operator forgot. That gap between the controlled field and the emergent one is the entire reason the signal works, and it is why operators who get serious about evading it stop editing header dicts and start impersonating the whole stack end to end.

What the signal proves in 2026

Header order is the cheapest honest signal a server gets, and casing was its HTTP/1.1-only companion until the traffic moved to HTTP/2 and HPACK folded the casing away. What is left in 2026 is a layered situation. Over the HTTP/1.1 that still carries a meaningful slice of traffic (older clients, internal services, some API endpoints), both axes are live, and a careless client leaks its toolkit through casing as readily as through order. Over the HTTP/2 and HTTP/3 that dominate browser traffic, casing is dead by mandate and order persists, now joined by the pseudo-header sequence, so the same fundamental measurement, “does the byte-level shape of this request match the client it claims to be,” carries forward intact even though one of its two original inputs is gone.

The durable lesson is the one the 2008 patent already understood. A client can set its User-Agent to any string in a single line of code. It cannot as easily change the order in which its networking stack emits fields, because that order is a property of the build rather than a configuration, and it cannot change the casing without reaching past the convenient API into the connection internals. The fields a client controls deliberately are the fields it will spoof. The fields it emits without thinking are the fields that give it away. Header order and casing sit squarely in the second category, which is precisely why a stripped, anonymized, User-Agent-less request is still wearing a name tag, written in the order of its own headers.


Sources & further reading

  • IETF (2022), RFC 9110: HTTP Semantics — defines field names as case-insensitive and states that the order of field lines with differing names is not significant, the loophole the whole technique exploits.
  • IETF (2022), RFC 9113: HTTP/2 — mandates lowercase field names and treats uppercase names as malformed, the change that erased the HTTP/1.1 casing signal.
  • Salusky & Thomas / AOL, later Verizon (granted 2014, priority 2008), US Patent 8,694,608: Client application fingerprinting based on analysis of client requests — describes CHSScore and CHSOrder built from header presence and order, with worked Firefox and IE examples.
  • lwthiker (2022), HTTP/2 fingerprinting — explains pseudo-header order, lowercasing, and how curl-impersonate restores browser-shaped signatures.
  • lwthiker, curl-impersonate — a special curl build that reproduces browser TLS and HTTP/2 behavior, including the header set and order browsers send.
  • ScrapeOps, Web Scraping Guide: Headers and User-Agents — concrete Chrome and Firefox header sequences and a note that Python requests does not reliably preserve a custom order.
  • iProyal, Are HTTP Headers Case Sensitive? — contrasts HTTP/1.1 wire-casing with HTTP/2’s lowercase mandate and surveys library casing defaults.
  • Yaakov Lazar, HTTP/2 Header Casing — the RFC 7540/9113 lowercase requirement and its link to HPACK’s lowercase static table.
  • MDN Web Docs, Sec-CH-UA header — documents the lowercase client-hint header family that Chromium ships and Firefox does not.
  • WebUnlocker, HTTP Header Analysis for Bot Detection — how header order and the missing client-hint and Sec-Fetch families flag non-browser traffic.
  • Scrapfly, HTTP/2 and HTTP/3 Fingerprinting — how pseudo-header order, SETTINGS, and header order combine into a protocol-level client signature.

Further reading