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.
Paste one gateway line. Every profile mints its own exitIn Mosaic: Provider minting. One gateway line, a separate exit for every 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.
How it works: Provider minting
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 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 and an early tunnel carries requests you thought were new. When a tunnel fails outright instead, ERR_TUNNEL_CONNECTION_FAILED is the error you will see.






