# Proxy-Connection: keep-alive, and the relay that missed it

> Chrome sends Proxy-Connection: keep-alive, lower case, to every proxy. Our relay sent Keep-Alive, no User-Agent and Connection: close, until we copied Chrome.

Source: https://mosaicbrowser.com/blog/the-relay-that-looked-like-a-script

## Our proxy relay told the gateway it was a script

Chrome sends Proxy-Connection: keep-alive, lower case, to every proxy. Our relay sent Keep-Alive, no User-Agent and Connection: close, until we copied Chrome.

![3: Our relay's CONNECT differed from Chrome's three ways](https://mosaicbrowser.com/covers/the-relay-that-looked-like-a-script.webp)

`Proxy-Connection: keep-alive` is the header a browser sends a proxy to say "keep this connection open after the request". Chrome sends it, in lower case, on every CONNECT and on every plain-HTTP request it routes through a proxy. Mosaic's own proxy relay, for a while, did not send it the way Chrome does, and the gateway on the other end was looking at a request shaped like a script's. Three lines were wrong, we copied Chrome's own code to fix them, and one line still differs.

## What Proxy-Connection: keep-alive is for

HTTP/1.0 had no persistent connections by default. Clients that wanted one sent `Connection: keep-alive`, and that worked until a proxy sat in the middle. An old proxy that did not understand the header would forward it to the website unchanged, the website would agree to keep the connection open, and the proxy, which had no idea it had agreed to anything, would sit waiting for the response to end.

`Proxy-Connection` was the workaround. A browser that knows it is talking to a proxy writes the keep-alive request under a name the website ignores, so a proxy that passes it along by mistake does no harm. It was never adopted into a finished standard, and the HTTP/1.1 specification mentions it mostly to discourage clients from sending it. Browsers kept sending it anyway, because removing a header every proxy has seen for decades gains nothing and risks an old proxy dropping connections.

So the header survives as a habit, and a habit is a fingerprint. Each client writes it one way, every time. Chrome writes `Proxy-Connection: keep-alive`, lower case, second in the request after `Host`. HTTP libraries make their own choices: some leave it out, some capitalise the value, some put it elsewhere.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Provider minting](https://mosaicbrowser.com/features/proxy-providers)

## Two hops, and only one of them was being measured

Chrome's `--proxy-server` switch cannot carry a username and password, and Chrome cannot sign in to a SOCKS5 proxy at all. So a Mosaic profile with an authenticated or SOCKS proxy talks to a relay on 127.0.0.1, and the relay talks to the gateway for it. One relay per profile, never shared, so two profiles cannot end up in one upstream connection pool.

The website end had been checked and was clean: the relay carries HTTPS as ciphertext through a tunnel and leaves plain-HTTP headers alone, which [header order through a proxy](https://mosaicbrowser.com/blog/chrome-header-order-through-a-proxy) goes through with the measurements. Nobody had looked at what the gateway receives. That hop reads the preamble in the clear, and it was ours.

We pointed a real Chrome at a recording proxy twice in one run, once directly and once through the relay, and compared the bytes:

```
CONNECT   no User-Agent at all
          Proxy-Connection: Keep-Alive   (Chrome: keep-alive)
          Proxy-Authorization second     (Chrome: last)

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

```

The capital K and the missing User-Agent are the kind of thing only a comparison finds. `Connection: close` was louder. It tells the gateway the client wants one request per socket and will open a new connection for the next one, which is how a script behaves and how no browser does. It was on every plain-HTTP request every proxied profile had sent.

## How Chrome builds the request we copied

We did not guess the fix. Chrome writes its tunnel request in `ProxyClientSocket::BuildTunnelRequest`, in `net/http/proxy_client_socket.cc`. It sets `Host`, then `Proxy-Connection: keep-alive`, then `User-Agent` when it has one, and only then merges the extra headers, which is where the proxy credentials arrive. That is why `Proxy-Authorization` lands last.

The relay in `src/main/proxy/relay.ts` now writes exactly that sequence. The User-Agent is not a string of ours: the relay reads it from the CONNECT the browser sent it and forwards it, because the relay stands in for this profile's browser and has no business claiming to be another one.

For plain HTTP there was an ordering trap. `Proxy-Connection`, like `Connection` and `Keep-Alive`, is hop-by-hop: it describes one link and must not be forwarded to the next. So the relay is right to drop the browser's copy, and then it has to write its own. The first version appended it at the end, which is still a difference, because Chrome puts it second. It is inserted straight after `Host` now, and the browser-shaped start of the request matches Chrome's line for line.

## The header we left different

One line still is not Chrome's, and we would rather say so than hide it. The relay is written in Node, and Node's HTTP client adds its own `Connection` header when it sends a request. Remove it and Node puts it back. The only thing we control is the value, so it is `keep-alive`, agreeing with the `Proxy-Connection` above it, and it sits last so everything before it is Chrome's order.

Writing the request bytes by hand would remove it, and we chose not to. The relay used to write raw bytes at a socket, and a bodyless GET then sent the gateway a half-close it read as "tear this down", so a proxy that answered `curl` perfectly showed up in the app as dead. The parser is worth one header.

A gateway you sign in to already knows exactly who you are, so this is not the surface a website fingerprints. It is the one hop that sees the preamble unencrypted, though, and a header that says "script" has no upside.

## How it stays fixed

Our build runs a relay-shape check that needs no proxy and no network. The relay is pointed at a socket that records the preamble and refuses, and the browser side is replayed from bytes a real Chrome was measured writing. It makes ten assertions, and each one was proved by putting its fault back: the capital K, the missing User-Agent and `Connection: close` each failed exactly one line.

The lesson that cost the most time was the control. The first comparison used Node's own HTTP client as "a client talking straight to the proxy". It sent `Host: 127.0.0.1:55062` and no `Proxy-Connection`, so the diff blamed the relay for things the test client was doing. A stand-in for a browser has to be a browser. If you are measuring your own setup, start the recording before Chrome launches, since [Chrome preconnects before you measure](https://mosaicbrowser.com/blog/chrome-preconnects-before-you-measure) and an early tunnel carries requests you thought were new. When a tunnel fails outright instead, [ERR_TUNNEL_CONNECTION_FAILED](https://mosaicbrowser.com/blog/err-tunnel-connection-failed) is the error you will see.

## Questions

### What is the Proxy-Connection header?

A request header a client sends to a proxy to ask it to keep the connection open, usually as Proxy-Connection: keep-alive. It was never part of a finished HTTP standard; browsers adopted it so old proxies that blindly forwarded headers would not pass a keep-alive request on to the website.

### Does Chrome still send Proxy-Connection: keep-alive?

Yes. Chrome writes it on the CONNECT request that opens an HTTPS tunnel and on plain-HTTP requests sent to a proxy, in lower case, straight after the Host header.

### Is Keep-Alive with a capital K different from keep-alive?

Not to a parser: connection options are case-insensitive. To anyone comparing request shapes it is different, because each client writes one spelling every time, and Chrome's is lower case.

### Should a proxy forward Proxy-Connection to the website?

No. It is a hop-by-hop header, like Connection and Keep-Alive, so it describes one link only. A proxy or relay drops it and, if there is another proxy after it, writes its own for the next hop.

## Related

[Header order is a fingerprint, and a tunnel cannot touch it](https://mosaicbrowser.com/blog/chrome-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.

[What ERR_TUNNEL_CONNECTION_FAILED is telling you](https://mosaicbrowser.com/blog/err-tunnel-connection-failed): Chrome's tunnel connection failed error means the proxy refused the CONNECT tunnel. In our runs the usual cause was a sticky session the gateway had retired.

[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.
