# Chrome HTTP header order through a proxy

> Sites read the order of Chrome's HTTP headers as well as their values. Through a CONNECT tunnel a relay carries ciphertext, so it cannot reorder a thing.

Source: https://mosaicbrowser.com/blog/chrome-header-order-through-a-proxy

## Header order is a fingerprint, and a tunnel cannot touch it

Sites read the order of Chrome's HTTP headers as well as their values. Through a CONNECT tunnel a relay carries ciphertext, so it cannot reorder a thing.

![The relay carries ciphertext, so the order arrives intact](https://mosaicbrowser.com/covers/chrome-header-order-through-a-proxy.webp)

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](https://mosaicbrowser.com/blog/akamai-http2-fingerprint-read-field-by-field).)

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](https://mosaicbrowser.com/blog/why-chromes-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.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Machines that exist](https://mosaicbrowser.com/features/real-fingerprints)

## 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](https://mosaicbrowser.com/blog/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](https://mosaicbrowser.com/guides/how-to-check-a-proxy-before-you-use-it).

## Questions

### Does Chrome send HTTP headers in a fixed order?

Yes. Chrome's network code writes its request headers in the same order on every request of a given kind, and HTTP/2 adds a fixed order for the four pseudo-headers. The exact order can shift between Chrome versions, so compare against the version you claim.

### How do I see the real header order Chrome sends?

Send a request to a server you control, or a recording proxy, and log the raw request. Chrome DevTools lists request headers alphabetically by default; the view source toggle in the Headers tab shows them closer to the order sent.

### Can a proxy change the order of my headers?

On plain HTTP it can, because it reads and rewrites the request. On HTTPS it cannot: the browser opens a CONNECT tunnel through the proxy and everything inside, headers included, is encrypted end to end with the site.

### What does the proxy itself see in a CONNECT request?

Only the short preamble that opens the tunnel: the target host and port, Proxy-Connection, a User-Agent and the proxy credentials. Chrome writes those in its own order too, and a relay that writes them differently looks like a script to the gateway.

## Related

[The Akamai HTTP/2 fingerprint, read field by field](https://mosaicbrowser.com/blog/akamai-http2-fingerprint-read-field-by-field): The Akamai HTTP/2 fingerprint is four fields: the SETTINGS values, the window update, any priority frames and pseudo-header order, all read before content.

[Why Chrome's JA3 hash changes on every connection](https://mosaicbrowser.com/blog/why-chromes-ja3-changes): Chrome's JA3 changes because Chrome shuffles its TLS extension order on every connection, and JA3 hashes that order. JA4 sorts first and holds still.

[Chrome opens connections before your test starts](https://mosaicbrowser.com/blog/chrome-preconnects-before-you-measure): We counted 1, 0 and 0 CONNECTs in three launches and thought Chrome was bypassing the proxy for HTTPS. Our counter was reset after Chrome had started.

[How to check a proxy before you use it](https://mosaicbrowser.com/guides/how-to-check-a-proxy-before-you-use-it): Check that a proxy answers and tunnels, where its exit really is, whether the address is residential or blocklisted, and that the browser stays behind it.

[The RTT client hint, read off the wire](https://mosaicbrowser.com/blog/rtt-on-the-wire): ECT, RTT and Downlink headers behind a proxy, measured: a claimed round trip moved JavaScript to 300 and left the RTT header at 50. So we claim neither.

[Same IPHey score, opposite reasons](https://mosaicbrowser.com/blog/iphey-same-score-different-signal): IPHey Unreliable on a normal Chrome is usually the exit. Our profile and a stock Chrome both got MX 90, for a browser signal and a location signal.

[Why IPHey's location card can be greener on a profile than on Chrome](https://mosaicbrowser.com/blog/iphey-location-card): On one US exit in one minute, stock Chrome got IPHey's masked or inconsistent location data and our profile got green. The difference was the timezone.

[A spoof that answers once: the geolocation bug we shipped](https://mosaicbrowser.com/blog/geolocation-answered-once): A geolocation spoofing timeout we shipped: the first getCurrentPosition got the exit's coordinates, every later call failed with code 3. One latch was to blame.

## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. Free holds 3 profiles. Pro is $49 a month for any number.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
