How uTLS mimics browser ClientHellos at the Go layer
Write a TLS client in Go and you inherit a fingerprint you did not choose. The standard crypto/tls package emits a ClientHello with its own cipher-suite order, its own extension set, and its own way of advertising supported groups, and that combination is nothing like what a real Chrome or Firefox sends. A passive observer reading the first unencrypted handshake message can tell your Go program apart from a browser before a single byte of HTTP crosses the wire. For a censorship-circumvention tool, that is the difference between blending in and getting blocked. For a scraper, it is the difference between a 200 and a challenge page.
uTLS exists to take that decision away from crypto/tls and hand it back to you. It is a fork of the standard library that exposes the ClientHello as a structure you can fill in byte for byte, plus a catalogue of pre-built “parrots” that reproduce the exact handshake of a named browser version. The question this post works through is how that forgery actually happens at the Go layer: what a parrot is, how the library reorders and pads and GREASEs the message to match a target, why the standard library is so distinctive in the first place, and the places where a forged ClientHello still betrays itself to anyone looking past the first packet.
The sections below start with why Go’s native handshake is a tell, then move into the ClientHelloID and the parrot catalogue, the anatomy of a Chrome spec inside the library, the fingerprinter that clones a captured handshake, and finally the leaks: the layers below and beside the ClientHello that uTLS does not touch, and the normalization and consistency checks that catch a forgery even when the bytes look right.
Why Go’s crypto/tls is a fingerprint
TLS handshakes begin in cleartext. The ClientHello is the very first message a client sends, and it has to be readable by a server that does not yet share any keys, so everything in it travels unencrypted. That message advertises which TLS versions the client supports, which cipher suites it prefers and in what order, which elliptic curves and signature algorithms it understands, and a list of extensions carrying things like the server name, ALPN protocols, and key shares. Every TLS stack makes slightly different choices, and those choices are stable per implementation. Hash the stable parts and you get a fingerprint that identifies the software, which is the whole basis of JA3 and its successor JA4. The field-by-field anatomy of the ClientHello is worth a separate read; here the point is narrower.
Go’s crypto/tls makes a coherent, sensible set of those choices, and that is exactly the problem. The cipher-suite list, the extension set, the supported-groups ordering, none of it matches any shipping browser, because Go was never trying to look like a browser. A server that keeps a table of known-good browser fingerprints sees a Go client and finds no match. Worse, the Go handshake is recognizable as Go. The fingerprint is distinctive enough that detection vendors ship signatures for it the same way they ship signatures for curl or python-requests.
There is a second-order wrinkle that makes naive Go clients even easier to flag. Go’s TLS stack has at times shuffled the order of some elements between connections, which sounds like camouflage but is the opposite. A real browser of a given version sends a consistent structure (or, since 2023, a consistently shuffled one within fixed rules, more on that below), so a client whose order drifts in a way no browser exhibits is itself anomalous. Variability that does not match any real population is a signal, not cover.
This is the gap uTLS was built to close. It does not try to make Go’s handshake “more secure” or “more random.” It makes the handshake reproduce a specific real-world target exactly, down to the GREASE placement and the padding length, so the fingerprint that comes out the other end lands inside a populated bucket of genuine browser traffic rather than in a bucket of one.
*The native Go ClientHello has its own cipher order and extension set and carries no GREASE, so it matches no browser. uTLS reconstructs the target browser's layout instead.*The ClientHelloID and the parrot catalogue
The unit of mimicry in uTLS is the ClientHelloID. It is a small struct with four fields: a Client string naming the software being imitated, a Version string for the version of that software, a Seed pointer used only when generating randomized fingerprints to seed the PRNG, and a Weights pointer used only by the randomized-spec generator. You do not normally build one by hand. The library ships a catalogue of predefined values, and you pick one.
Using the library is a one-line substitution. Where a normal program calls tls.Client(conn, config), a uTLS program calls tls.UClient(conn, config, clientHelloID) and passes one of those catalogue entries. From that point the connection carries the chosen identity. The handshake itself is still driven by code inherited from crypto/tls; uTLS changes what the ClientHello looks like and gives you low-level hooks into the rest, but it does not reimplement the cryptographic state machine. The README is blunt about the scope: the library “merely changes ClientHello part of it and provides low-level access.”
The catalogue is large and version-pinned. On the Chrome side it runs through a long sequence including HelloChrome_58, HelloChrome_62, HelloChrome_70, HelloChrome_83, HelloChrome_96, HelloChrome_100, HelloChrome_102, HelloChrome_120, HelloChrome_131, and HelloChrome_133, plus specialized entries whose names encode which features they carry: HelloChrome_100_PSK, HelloChrome_106_Shuffle, HelloChrome_112_PSK_Shuf, HelloChrome_114_Padding_PSK_Shuf, HelloChrome_115_PQ, and HelloChrome_115_PQ_PSK. Those suffixes track real changes in Chrome’s handshake over time: PSK for the pre-shared-key extension, Shuffle for extension permutation, Padding for the padding extension, and PQ for the post-quantum key share. Firefox has its own line from HelloFirefox_55 up through HelloFirefox_120 and HelloFirefox_148, and there are entries for Safari, Edge, iOS, Android’s OkHttp client, and a couple of Chinese browsers (Hello360_*, HelloQQ_*).
Each version-pinned ID corresponds to a hand-maintained specification of that exact browser build’s handshake. That is the maintenance burden the original uTLS paper warned about, made concrete: every meaningful change in Chrome’s ClientHello needs a new entry, and a parrot that lags the real browser population is a parrot that no longer blends in.
For people who do not want to chase versions, the catalogue also has HelloChrome_Auto and the equivalent *_Auto entries for the other browsers. HelloChrome_Auto resolves to what the library considers the recommended, usually latest, Chrome parrot. It is convenient and it is a trap in equal measure, because “latest the library knows about” and “latest in the wild” diverge the moment Chrome ships a handshake change before uTLS does. Pinning a version at least makes the lie explicit.
There are three more entries that are not parrots at all. HelloGolang falls back to the default crypto/tls marshaling, which is to say no mimicry. HelloCustom gives you an empty slate to fill in yourself. And HelloRandomized, with its HelloRandomizedALPN and HelloRandomizedNoALPN variants, generates a synthetic fingerprint that adds and reorders extensions and cipher suites under the control of that Seed and Weights. Randomization is a different strategy from parroting, and a weaker one against modern detection, for reasons the closing sections get to.
Anatomy of a parrot: the ClientHelloSpec
Behind every version-pinned ID is a ClientHelloSpec, and the function utlsIdToSpec is what turns one into the other. The spec is the real object of interest because it is the actual description of the handshake. It carries the CipherSuites slice in the exact order the target sends them, a CompressionMethods slice that for modern browsers is just compressionNone, the Extensions slice in target order, and TLSVersMin and TLSVersMax bounds. Marshal that and you get the bytes; get the slice contents and ordering right and you get the fingerprint.
The extensions are where the detail lives, because a browser’s identity is mostly in which extensions it sends and how. uTLS models each one as its own type implementing a common TLSExtension interface. A Chrome spec strings together SNIExtension for the server name, ExtendedMasterSecretExtension, RenegotiationInfoExtension, SupportedCurvesExtension and SupportedPointsExtension for the elliptic-curve parameters, SessionTicketExtension, SignatureAlgorithmsExtension, StatusRequestExtension and SCTExtension for certificate handling, ALPNExtension for protocol negotiation, KeyShareExtension, PSKKeyExchangeModesExtension, and SupportedVersionsExtension. Newer Chrome specs add UtlsCompressCertExtension for certificate compression and ApplicationSettingsExtension for the ALPS extension Chrome uses to negotiate settings. Each type knows how to write itself onto the wire in the byte layout the real browser uses, which is why getting the list right reproduces the fingerprint and not merely an approximation of it.
Two pieces of the Chrome handshake are doing more work than they look. The first is GREASE, the deliberate insertion of reserved, meaningless values into cipher lists, extension lists, supported groups, and the like, so that servers stay tolerant of unknown values and the protocol does not ossify. Chrome sprinkles GREASE through its ClientHello, and any honest parrot has to as well. uTLS represents it with a GREASE_PLACEHOLDER constant in the cipher and group slices and a UtlsGREASEExtension in the extension list; at marshal time these get filled with valid GREASE values. A ClientHello with no GREASE at all is one of the cheapest tells there is, because every modern Chrome and Firefox carries it. The mechanics and pitfalls of GREASE in fingerprinting get their own treatment, but the short version is that uTLS has to emit it to be believable.
The second is extension order. Since Chrome 110, shipped around the 20th of January 2023, Chrome permutes the order of its ClientHello extensions on every connection, with the one fixed rule from the TLS 1.3 specification that pre_shared_key, if present, has to be last. The reason is the same anti-ossification logic as GREASE: by refusing to send a stable order, Chrome stops middleboxes and servers from depending on one. The side effect was that JA3, which hashes extensions in the order they appear, broke for Chrome overnight. Fastly measured the legacy Chrome JA3 dropping off sharply starting that day, and estimated that with roughly fifteen-factorial possible orderings (on the order of 10^12) essentially every Chrome connection would now produce a distinct JA3. The Chrome randomization that broke JA3 is the cleaner full story.
uTLS matches this with a ShuffleChromeTLSExtensions helper, and Chrome specs from the relevant versions wrap their extension slice in it so the parrot permutes the same way the real browser does, while keeping pre_shared_key pinned to the end. Note what this means: the parrot is not faithful by sending a fixed order, it is faithful by being as random as Chrome is, within Chrome’s rules. A parrot that sent a stable extension order would now be the anomaly. This is the inversion that makes mimicry harder than it sounds. Once the target randomizes, fidelity means reproducing the randomization, not freezing a snapshot.
Cloning a captured handshake
Maintaining a parrot per browser version is work, and it always lags. uTLS offers a second path that sidesteps the catalogue: take a real ClientHello you captured off the wire and reconstruct it as a spec. The Fingerprinter type does this. You feed it the raw bytes of a genuine ClientHello, it parses the structure, and it produces a ClientHelloSpec that reproduces the same cipher suites, extensions, and ordering. You then attach that spec to a connection by starting from HelloCustom and calling ApplyPreset with the generated spec, and the next handshake your Go client sends should carry the captured handshake’s properties.
This is the closest the library gets to its original research goal, which was to make mimicry track the real population automatically instead of through hand-maintained snapshots. If you can sample current Chrome traffic, you can clone current Chrome without waiting for anyone to write a new HelloChrome_* entry. It is also the honest way to mimic a browser uTLS has no parrot for at all.
The cloning is not magic, and the README is clear about the seams. A captured spec captures what was in the bytes, but some elements of a handshake are not fully determined by a single capture, and uTLS cannot reproduce features it does not implement. If a target advertises a cipher suite or extension that uTLS does not support, and the server selects it, the handshake can break, because the library has to actually complete the negotiation it advertised. Parroting only extends to the ClientHello; the rest of the connection still runs on inherited crypto/tls machinery, and that machinery has to be able to honor whatever the forged ClientHello promised. Advertising a capability you cannot fulfill is its own kind of tell, and a way to fail closed.
There is also the matter of the bits that change per connection by design. Session tickets and pre-shared keys, key shares, the GREASE values themselves, the shuffled order, none of these are constant, so a faithful clone has to regenerate them correctly rather than replay a frozen capture. uTLS exposes session-state control for this, with methods to set a session state and a session cache so resumption behaves like a browser’s would. The session-resumption fingerprint is its own signal surface, and a clone that resumes in a way the target never would is back to looking synthetic. Cloning the ClientHello is necessary; making the whole session coherent around it is the harder part.
Where uTLS still leaks
A perfect ClientHello is one layer of a connection, and detection in 2026 does not stop at one layer. The first and most quoted leak is HTTP/2. After the TLS handshake completes, an HTTP/2 client sends a SETTINGS frame and a connection-level WINDOW_UPDATE, and the values it chooses are implementation-specific. Akamai formalized this into a fingerprint, described in a 2017 Black Hat Europe whitepaper by Shuster, with a format that strings together the SETTINGS parameters, the WINDOW_UPDATE increment, any priority information, and the order of the HTTP/2 pseudo-headers like :method, :authority, :scheme, and :path. Chrome sends one characteristic set of values, Firefox another, and Go’s net/http2 sends Go’s. uTLS forges the TLS handshake and stops there; the HTTP/2 layer is a different package entirely. So a client can present a flawless Chrome JA4 and then immediately send a Go SETTINGS frame, and a detector that checks both, which Akamai and others do, sees the contradiction. How DataDome uses HTTP/2 fingerprints walks through exactly that cross-check. Closing this leak means swapping in an HTTP/2 stack whose frame values match the same browser the TLS layer claims, which is real work outside uTLS itself.
The second leak is normalization, and it is aimed squarely at the randomization features. JA3 hashed extensions in order, so shuffling defeated it. JA4, from FoxIO, was designed after the shuffle and normalizes on purpose: it sorts the cipher suites and extensions into a canonical order and ignores GREASE before hashing. The consequence is that Chrome’s extension permutation, and uTLS’s faithful reproduction of it, collapse back to a single stable JA4. Randomizing the order buys nothing against a fingerprint that sorts first. Worse, the HelloRandomized strategy, which invents an order no real browser uses, normalizes to a JA4 that matches no known browser population, which is detectable as a non-browser even though every individual connection looks different. Cloudflare went further with JA4 inter-request signals, published in August 2024, computing aggregate features over the last hour of traffic, browser ratio and cache ratio per fingerprint, so a forged JA4 that statistically behaves nothing like the browser it claims gets flagged on context rather than on the bytes. Against a system that sorts and then correlates, a random fingerprint is the easy case to catch.
The third leak is the one that hurts most, because it lives inside the mimicry: internal inconsistency. A parrot is only as good as its model of the target, and when the model is subtly wrong, the result is a fingerprint that no real browser would ever produce, which is a higher-confidence tell than merely looking like Go. The cleanest documented example surfaced as a 2025 advisory against uTLS’s own Chrome parrots. When Chrome generates a ClientHello with a GREASE ECH extension, it picks the cipher suite for the outer handshake and the inner ECH consistently. The uTLS Chrome parrots, across HelloChrome_120, HelloChrome_120_PQ, HelloChrome_131, and HelloChrome_133, hardcoded an AES preference for the outer cipher while choosing the ECH cipher randomly between AES and ChaCha20, which produced an impossible combination, outer AES with inner ChaCha20, on roughly half of connections. No real Chrome emits that, so a passive observer could flag the mismatch with high confidence. The behavior was present from late 2023 until a fix landed in October 2025, with the recommendation to move to uTLS 1.8.1 or later. The lesson is general. Detecting curl-impersonate and uTLS is mostly a catalogue of these internal contradictions, and they exist because a hand-built model of a browser is never quite the browser.
What the library actually buys you
uTLS solves a real and bounded problem. It takes Go’s distinctive, browser-unlike ClientHello and replaces it with a faithful reconstruction of a chosen browser’s, GREASE and shuffle and padding and all, either from a hand-maintained parrot or from a captured handshake you clone. For the censorship-circumvention use case it was built for, that is often enough, because the adversary is frequently a deep-packet-inspection box doing first-order ClientHello matching, and a parrot that lands in a real browser bucket defeats it. The original 11.8-billion-connection study that motivated the library was right that the hard part is not generating one good ClientHello but keeping pace with a target that changes, and the version-pinned catalogue is the visible cost of that truth.
What the library does not do is make a Go program indistinguishable from a browser, and treating it as if it did is where people get caught. The ClientHello is the first message, not the only one. A forged handshake followed by a Go HTTP/2 SETTINGS frame is a contradiction any two-layer detector reads instantly. A randomized fingerprint that beats JA3 normalizes to a single JA4 and then to a population signal that beats it back. And a parrot whose internal model is even slightly wrong, an AES-versus-ChaCha20 disagreement that no real Chrome would ever emit, is a sharper tell than the plain Go fingerprint it replaced, because impossibility is easier to flag than mere unfamiliarity. The mimicry problem does not get solved at the TLS layer; it gets relocated to the next layer up, and the next, until the whole session has to cohere or none of it does.
The honest read on uTLS in 2026 is that it raises the floor and moves the fight. It makes the cheapest tell, a raw Go ClientHello, go away, and in doing so it forces detection to spend its budget on HTTP/2 frames, JA4 normalization, inter-request statistics, and consistency checks across the handshake. Each of those is harder and more expensive to run than a single JA3 lookup. That is the actual value: not invisibility, but pushing the detector off the one cheap signal and onto a stack of more costly ones. The parrot catalogue dating itself one Chrome release at a time, in the same repository that warned the catalogue would always lag, is the clearest reminder that this is an arms race with a clock, and the clock favors whoever ships the next handshake change first.
Sources & further reading
- Frolov, S. and Wustrow, E. (2019), The use of TLS in Censorship Circumvention — the NDSS paper that introduced uTLS, measuring 11.8 billion connections over 9 months and showing why parroting Signal, Lantern, Snowflake, Psiphon, and meek was hard to keep faithful.
- Refraction Networking (2024), utls README — the library’s own description of ClientHello mimicry, UClient usage, the Fingerprinter/HelloCustom workflow, and stated limitations.
- Refraction Networking (2024), utls u_common.go — the ClientHelloID struct and the full predefined catalogue of Chrome, Firefox, Safari, Edge, iOS, and Android parrots.
- Refraction Networking (2024), utls u_parrots.go — utlsIdToSpec, the ClientHelloSpec, the per-extension types, GREASE_PLACEHOLDER, and ShuffleChromeTLSExtensions.
- Fastly (2023), A first look at Chrome’s TLS ClientHello permutation in the wild — measured the legacy Chrome JA3 dropping off from 20 January 2023 and the ~15! orderings that made each connection’s JA3 effectively unique.
- net4people/bbs (2023), Google Chrome TLS extension permutation — the discussion thread tracking Chrome’s extension-shuffle rollout and the pre_shared_key-last rule.
- Cloudflare (2024), Advancing threat intelligence: JA4 fingerprints and inter-request signals — JA4’s sort-and-drop-GREASE normalization and the August 2024 inter-request signals computed over the last hour of traffic.
- Shuster, R. et al. (2017), Passive Fingerprinting of HTTP/2 Clients — the Akamai Black Hat EU whitepaper defining the SETTINGS/WINDOW_UPDATE/priority/pseudo-header HTTP/2 fingerprint.
- Scrapfly (2026), Post-Quantum TLS: Why Scraping Tools Are Now Exposed — Chrome 124’s April 2024 default X25519MLKEM768 key share and how its absence dates an outdated parrot.
- DailyCVE (2025), uTLS Fingerprint Mismatch advisory — the GREASE-ECH outer/inner cipher inconsistency in HelloChrome_120 through HelloChrome_133, fixed around uTLS 1.8.1 in October 2025.
- Stamus Networks (2024), JA3 fingerprints fade as browsers embrace TLS extension randomization — context on how extension randomization broke order-dependent fingerprinting.
Further reading
Detecting curl-impersonate and uTLS: the second-order tells
How detectors catch tools that forge a perfect browser ClientHello: the mismatch between the TLS layer and the HTTP/2 frames above it, library-specific residue, header order, and version drift.
·23 min readHow Cloudflare uses TLS and HTTP/2 fingerprints in bot scoring
A reference on Cloudflare's network-layer fingerprinting: how JA3, JA4, and the HTTP/2 frame profile are computed at the edge, what cf.bot_management exposes, and how those signals feed the 1-99 bot score.
·23 min readTLS fingerprinting: from ClientHello bytes to JA4
What a ClientHello actually contains, why JA3 worked for six years and then stopped, and what JA4 fixes, with a Python reference you can run against your own packet captures.
·15 min read