17°
SystemsNetworks

PhantomRelay: why your automation gives itself away before the first HTTP byte

A Node HTTP client is distinguishable from real Chrome in the TLS handshake, before sending a single header. How I built a browser relay with a Rust addon over BoringSSL, cost-based escalation and nearly 900 tests.

Efrain Garay 18 August 2026

You have an end-to-end test suite running against your own staging. One day you put a WAF in front and the suite starts failing: not because of a bug of yours, but because the WAF decided your automation does not look like a browser. And it is right, though not for the reason you would expect. It did not detect it by the User-Agent or by navigator.webdriver. It detected it earlier: in the TLS handshake, before the first HTTP header was sent.

PhantomRelay came out of that problem.

PhantomRelay in 28 seconds: the TLS handshake that gives you away, why OpenSSL and BoringSSL do not produce the same bytes, and why the fix was to inherit rather than imitate. Muted by default: turn it on in the controls.Watch it in the reel viewer →

The ClientHello gives you away

When you open a TLS connection, the first thing you send is a ClientHello: a message in plain text (there is no encryption yet) announcing which versions you support, which cipher suites you prefer and in what order, which extensions you carry, which elliptic curves you accept and which protocols you announce over ALPN.

That message is not the same across clients, and there is the problem: its structure depends on the TLS library that generates it. Node.js uses OpenSSL. Chrome uses BoringSSL, Google’s own fork. Even if you configure both for “the same thing”, the byte stream comes out different.

An observer takes that ClientHello, boils it down to a hash, and already knows what it is talking to. That is JA3, which hashes the list as it travels, in its original order.

The same question, asked by two clientsCaptured on 18 August 2026 against tls.browserleaks.com/json, from the same machine, minutes apart.

JA4

t13d1516h2_8daaf6152771_806a8c22fdea

  • tTCPover TCP, not QUIC
  • 13TLS 1.3the highest version negotiated
  • dwith SNIannounces the domain in the clear
  • 15ciphershow many ciphers it offers
  • 16extensionshow many extensions it carries
  • h2ALPNfirst and last letter of the ALPN

Fifteen cipher suites and sixteen extensions. Part b, 8daaf6152771, is exactly the one FoxIO publishes as Chrome’s fingerprint in its own repository.

JA3
2bcce27ad3a76dede43f4e8775366cb0
HTTP/2 SETTINGS
1:65536;2:0;4:6291456;6:262144

JA4

t13d4907h2_0d8feac7bc37_7395dae3b2f3

  • tTCPover TCP, not QUIC
  • 13TLS 1.3the highest version negotiated
  • dwith SNIannounces the domain in the clear
  • 49ciphershow many ciphers it offers
  • 07extensionshow many extensions it carries
  • h2ALPNfirst and last letter of the ALPN

Forty-nine cipher suites and seven extensions. It offers three times more ciphers and less than half the extensions: there is no need to compare hashes, the counts already separate them.

JA3
2bab0327a296230f9f6427341e716ea0
HTTP/2 SETTINGS
no HTTP/2 negotiated in this capture

JA4

t13d1516h2_acb858a92679_28ef7e58d5d7

  • tTCPover TCP, not QUIC
  • 13TLS 1.3the highest version negotiated
  • dwith SNIannounces the domain in the clear
  • 15ciphershow many ciphers it offers
  • 16extensionshow many extensions it carries
  • h2ALPNfirst and last letter of the ALPN

Same client and same handshake as the first card. Part a does not move, but b and c are different: without sorting, Chrome’s shuffling changes the fingerprint on every connection. Sorting is what makes it stable.

JA3
same client, same handshake
HTTP/2 SETTINGS
1:65536;2:0;4:6291456;6:262144

JA4, its successor, does exactly the opposite of what you would expect: it sorts the cipher suites and extensions in hexadecimal before hashing them. That sounds like losing information, and it is deliberate. In 2023 Chrome started shuffling the order of its extensions on every connection, precisely so nobody could track it by JA3, and incidentally so servers would not build logic assuming a fixed fingerprint. Sorting neutralizes that defence. The entropy JA4 loses by sorting it recovers elsewhere: it adds the signature algorithms, the ALPN and the counts of ciphers and extensions.

The differences that matter are four:

  1. Which cipher suites you offer. For JA3, also in which order you prefer them; JA4 only looks at the set. Every stack has its own.
  2. Which extensions you carry, and how many. This is the real wall, and it is not the order: it is that Node does not let you choose the set of extensions OpenSSL emits, nor the signature algorithms it announces. The count goes into the hash as it is, so one extension more or less already separates you from Chrome no matter how right you get everything else.
  3. ALPN. Chrome announces HTTP/2 before HTTP/1.1. Many clients announce only one, or the other way round, and that goes into the hash.
  4. GREASE (RFC 8701). Chrome inserts pseudorandom reserved values among its ciphers and extensions, on purpose, so that middleboxes do not ossify assuming the list never changes. A stack that does not emit them has a different silhouette.

The decision: inherit rather than imitate

You can try patching OpenSSL so its output resembles BoringSSL’s. It is a losing race: every Chrome version changes something, and JA4’s own author warns that fingerprints change roughly once a year, as the libraries update. Chasing that from the outside is signing up for eternal maintenance.

The architectural decision was the opposite: do not imitate Chrome’s ClientHello, inherit it. A native Node addon written in Rust with NAPI-RS, resting on boring (the BoringSSL binding Cloudflare maintains) and hyper-boring for HTTP/2.

It is 377 lines of Rust across five files. They configure the connector with Chrome’s profile (cipher suites, curves, signature algorithms, ALPN, SCT and OCSP stapling, with certificate verification always on) and return the builder unfinalized, on purpose, so the HTTP layer can install its own callbacks before closing it.

The elegant part is what you do not have to write: the extension order and the GREASE values are not manufactured. They come out on their own, because the same library Chrome uses produces them.

The fingerprint does not end at TLS. There are two more layers:

  • HTTP/2: the connection’s SETTINGS also identify the client. Chrome uses a HEADER_TABLE_SIZE of 64 KB where Node uses 4 KB, and much larger initial windows. The profile’s values get replicated.
  • Header order: HTTP/2 requires the pseudo-headers to come first, but the order of the rest is free, and every browser has its own. The sequence captured from a real Chrome with mitmproxy is pinned.

The native addon is optional, always

A hard rule of the repository: the native package can never be mandatory. It loads inside a try/catch and there is graceful degradation to Node’s TLS path if the binary did not compile, if the platform does not match, or if Rust is not available. In the tests it is declared external, so the suite runs without compiling anything.

The monorepo (Turborepo + pnpm, 13 packages, strict TypeScript) has an explicit dependency DAG where that package hangs as an optional leaf. Installing the project on a machine with no Rust toolchain works; you simply lose one layer.

Escalate by cost, not by default

The design’s second idea: use the cheapest level that works.

There are four modes, cheapest to most expensive: a pure HTTP request with the right network profile (tens of milliseconds), a headless Chrome driven by CDP, that Chrome with simulated human behaviour, and finally a persistent, aged browser profile.

In automatic mode the orchestrator enters at the cheapest one and only escalates when a detector determines the response is no good. Most of the time there is no need to start a browser at all. That is the difference between 30 milliseconds and 15 seconds per request, multiplied by every request in a QA suite.

All of this is decomposed with dependency injection: the orchestrator receives the browser fleet, the detector, the proxy controller, the action executor and the session manager as interfaces. The escalation logic is tested in full without starting Chrome.

Three pieces I am happy with

Distributed rate limiting that degrades on its own

A sliding window over a Redis sorted set, resolved in a four-command MULTI: add the timestamp, purge whatever fell outside the window, count, and renew the TTL. If the count exceeds the limit, the just-inserted entry is removed and the time until the quota frees up is computed.

The good part is not that, it is the fallback: the Redis limiter internally composes an in-memory one. If Redis does not answer, it marks the connection as down and delegates, biased toward allowing. The initial connection never blocks service startup. A downed Redis degrades the limit’s precision; it does not take the relay down.

Hysteresis in proxy health

Every proxy carries an exponential moving average of its successes. The detail is in how the events are emitted: degraded when the score crosses downward one threshold, recovered when it crosses upward a higher one.

Two different thresholds, not one. With a single threshold, a proxy oscillating around the limit emits an event per request and saturates the bus. With hysteresis, it has to genuinely recover before being considered healthy again. And the comparison is between the previous score and the new one: it fires on the transition, not on the state.

A CDP relay that does not understand CDP

The WebSocket bridge that exposes Chrome’s protocol outward forwards the frames as they are, without interpreting them. It is a defensive decision: if the relay generated messages of its own, those messages would be a signal. Interpreting nothing is the simplest guarantee that it introduces no artifacts.

It had its implementation cost: the HTTP-to-WebSocket upgrade had to be handled by hand, because the library aborts the handshake on routes that are not its own and was corrupting sockets already promoted by another part of the server.

The use case, turned into a product

My favourite part of the project is the one that has least to do with fingerprinting: a QA runner of about 1,400 lines that uses everything above to test applications of my own. It runs the suites, isolates the browser profile per suite so cookies do not leak between tests, and records one MP4 per test assembled from Chrome’s screencast frames.

It is the best demonstration of the thesis: the infrastructure exists so that legitimate automation does not break.

The numbers

  • 5 services in Docker: the relay, a dedicated Chrome, Redis, Prometheus and Grafana. Each with a healthcheck, and the relay waiting for its dependencies to be healthy.
  • ~900 tests across 56 files (the README still says 732: it fell behind).
  • ~20,600 lines of TypeScript excluding tests, plus 377 of Rust.
  • The Chrome container runs with a seccomp profile of its own, because Chromium needs system calls Docker’s default profile does not allow. The easy alternative would have been --privileged or disabling the sandbox; opening the minimum costs more and is the right thing.

And the ones missing, said without makeup: there is no public reproducible benchmark of success rate, and with a proxy in the cheapest mode it falls back to Node’s TLS path, which does not control extension order. Those are on the roadmap, not among the achievements.

What I take away

TLS fingerprinting is almost always discussed as an evasion topic. I find it more interesting as a stack compatibility problem: two libraries implementing the same RFC produce distinguishable bytes, and that detail, invisible to 99% of software, decides whether your test suite runs or fails.

The solution was not cleverness either: it was using the same piece the browser uses, and accepting the cost of putting Rust inside a TypeScript monorepo, with the care to keep working when that Rust is not there.

Sources

  • JA4 specification, FoxIO. The complete algorithm: what goes into each of the hash’s three parts and why ciphers and extensions are sorted in hexadecimal.
  • FAQ in the JA4 repository. The author’s answer to “why do you sort the extensions?”: Chrome moved to shuffling them in 2023, and adding the signature algorithms is what recovers the uniqueness.
  • Randomize TLS extension order, on Chrome Platform Status. The Chromium change that caused all of the above.
  • JA3, by Salesforce. The original implementation, which hashes the list in its arrival order.
  • RFC 8701. GREASE: why a browser deliberately announces reserved values nobody is going to negotiate.
  • browserleaks.com/tls. Against this endpoint I verified that the relay’s fingerprint matches a real Chrome’s.

Comments

No comments yet. The first one is yours.

Reviewed before publishing. The email is not stored and never appears anywhere.