Chrome sends its HTTP headers in a fixed order, and a site can read that order as well as the values. A client that copies Chrome's User-Agent but writes its headers in its own library's order has contradicted itself before the page loads. When we put a local relay between Mosaic's browser and its proxy, the first question was whether the relay changed what sites see. On HTTPS it cannot, and on plain HTTP we measured that it does not.

Why Chrome's HTTP header order is a fingerprint

A request is a list of lines, and HTTP does not say what order they go in. Each client decides once, when its network code is written, and then repeats that decision on every request. Chrome writes its own sequence for a navigation, a different one for a script fetch, and HTTP/2 adds four pseudo-headers, :method, :authority, :scheme and :path, in a fixed order of their own. (The pseudo-header order is one of the four fields of the Akamai HTTP/2 fingerprint.)

Python's requests, Go's net/http and Node's http each have their own order, and most of them never send some headers Chrome always sends. The server reads the order before it serves anything, and no page script can change it, because it is written below JavaScript, in the network stack. So header order sits with the TLS handshake and the HTTP/2 settings as a check on whether the thing claiming to be Chrome is Chrome. The order can also move between Chrome releases, the way Chrome's JA3 changes, so it has to match the version the User-Agent names.

To see the order your own browser writes, log the raw request at a server you control. Chrome DevTools sorts request headers alphabetically by default, which hides exactly the thing you want to see.

Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Machines that exist

What a relay in the middle can and cannot change

Chrome's --proxy-server switch cannot carry a username and password, and Chrome cannot authenticate to a SOCKS5 proxy at all. So a Mosaic profile with an authenticated or SOCKS proxy talks to a relay on loopback, and the relay talks to the gateway for it. That is two hops and two questions: what does the website see, and what does the gateway see.

For HTTPS, which is nearly every request a site gets, the first answer is structural. Chrome sends the relay a CONNECT example.com:443 request, the relay opens a tunnel, and from then on it carries bytes it cannot read. The TLS handshake, the HTTP/2 frames and every header inside them, order included, are encrypted between the browser and the site. The relay has no way to reorder a header it never sees.

For plain HTTP the relay does read the request, so we measured it. A real Chrome driven through the relay at httpbin.org/headers, against the same request sent straight at the upstream proxy, came back with every header identical. The one difference was X-Amzn-Trace-Id, a nonce Amazon's load balancer adds that differs between any two requests, so it is not a signature of anything.

The hop that did see a difference: the gateway

The second question had never been asked, and it found real faults. We pointed a real Chrome at a recording proxy, once directly and once through our relay, in the same run, and compared what arrived:

CONNECT   no User-Agent at all
          Proxy-Connection: Keep-Alive where Chrome writes keep-alive
          Proxy-Authorization second instead of last

GET       Proxy-Connection: keep-alive dropped
          Connection: close added

Connection: close was the loud one. It asks for one request per connection, which is what a script does and what no browser does, and it was on every plain-HTTP request any profile had sent through the relay.

The fix was copied from Chrome rather than guessed. Chrome builds its tunnel request in ProxyClientSocket::BuildTunnelRequest, in net/http/proxy_client_socket.cc: Host first, then Proxy-Connection: keep-alive, then User-Agent, and the authorization header last because it arrives with the extra headers merged at the end. Our relay now writes exactly that, and it forwards the User-Agent from the CONNECT the browser sent it, so the relay never claims to be a different browser from the one it stands in for.

Chrome -> relay -> gateway         Chrome -> gateway
CONNECT example.com:443 HTTP/1.1   CONNECT example.com:443 HTTP/1.1
Host: example.com:443              Host: example.com:443
Proxy-Connection: keep-alive       Proxy-Connection: keep-alive
User-Agent: (the browser's own)    User-Agent: (the browser's own)
Proxy-Authorization: Basic ...

Order mattered here too. Proxy-Connection is a hop-by-hop header, so the relay drops the browser's copy and writes its own, and at first it appended it at the end, where Chrome puts it second. It is now inserted straight after Host, and the GET's browser-shaped opening lines match Chrome's.

One header is still different, and we state it rather than hide it. Node, which the relay is written in, adds its own Connection header when it sends a request and puts it back if it is removed, so the only thing we control is the value. It is keep-alive, agreeing with Proxy-Connection, and placed last so everything before it matches.

A gateway you log in to already knows who you are, so this is not the surface a website fingerprints. It is the one hop that reads the preamble in the clear, though, and a preamble that says "script" gains nothing. Our build runs a relay-shape check with ten assertions against bytes a real Chrome was measured writing, and each assertion was proved by putting its fault back and watching it fail.

Checking it on your own setup

If you run a relay or a local proxy of your own, measure both hops with a real browser at each end, not an HTTP library standing in for one. Our first control here was Node's HTTP client playing "a client talking straight to the proxy", and it sent a different Host and no Proxy-Connection, so the first diff blamed the relay for things the test client did. And start the count before the browser does, because Chrome preconnects before you measure and an early tunnel can carry requests you thought were new. Whether the exit itself is worth using is a separate question, covered in how to check a proxy before you use it.