TLS Fingerprinting with utls & tls-client
The one scraping-specific reason this sub-path exists. What a TLS fingerprint is, why anti-bots check it, and how Go's utls and tls-client libraries spoof it.
What you’ll learn
- Explain a TLS ClientHello and which fields make up a JA3/JA4 fingerprint.
- Run a fetch with utls that impersonates Chrome's exact handshake.
- Use bogdanfinn/tls-client from Go (and call it from Python via its HTTP API).
- Diagnose 'blocked even though my headers look right' as a TLS fingerprint problem.
You've made it to the lesson this whole sub-path was for. TLS fingerprinting is the dominant anti-bot technique in 2026, and Go is where you go to defeat it cleanly.
If you've ever sent a perfectly-headered request, with the right cookies, the right User-Agent, the right Accept-Language, and still been blocked by Cloudflare or DataDome before the page even loaded: this lesson is the answer.
What's a TLS fingerprint
When your client connects to an HTTPS server, the first packet it sends is a ClientHello. It contains, in this exact order:
- TLS version offered (e.g. 1.3, 1.2).
- A list of cipher suites in the client's preference order.
- A list of TLS extensions (SNI, ALPN, key share, supported groups, etc.).
- For each extension, its content (e.g. for "supported_groups": the elliptic curves the client supports, in order).
The combination and order of these is highly distinctive. Two different HTTP clients (Chrome vs Python requests vs curl vs Go's net/http) produce noticeably different ClientHellos.
The most popular fingerprint format is JA3: a hash of (TLS version, cipher suites, extensions, elliptic curves, point formats). The successor JA4 is similar but more granular.
Anti-bot vendors maintain a database of "known-good" JA3/JA4 hashes (Chrome 120, Firefox 122, Safari 17, etc.) and flag everything else. Your Python requests library hashes to a known-bad JA3 the moment it touches the network. You're blocked before HTTP even starts.
Why Go's standard library is also blocked
Go's crypto/tls has its own fingerprint. So does Java's. So does .NET's. The web is full of "browser fingerprint or you don't get in" sites, and any non-browser fingerprint trips them.
The solution: rewrite the TLS stack to emit a ClientHello indistinguishable from a real browser's. That's exactly what utls does.
utls: the low-level trick
github.com/refraction-networking/utls is a fork of Go's crypto/tls that lets you specify which ClientHello to send, byte-for-byte. It ships with profiles for Chrome, Firefox, Safari, Edge, iOS Safari, and others.
package main
import (
"fmt"
"io"
"net"
"net/http"
utls "github.com/refraction-networking/utls"
)
func main() {
dialer := func(network, addr string) (net.Conn, error) {
rawConn, err := net.Dial(network, addr)
if err != nil {
return nil, err
}
config := &utls.Config{ServerName: "practice.scrapingcentral.com"}
uConn := utls.UClient(rawConn, config, utls.HelloChrome_120)
if err := uConn.Handshake(); err != nil {
return nil, err
}
return uConn, nil
}
transport := &http.Transport{
DialTLS: dialer,
}
client := &http.Client{Transport: transport}
resp, err := client.Get("https://practice.scrapingcentral.com/")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, string(body[:200]))
}
utls.HelloChrome_120 is the preset that emits Chrome 120's exact ClientHello. The handshake the server sees is byte-identical to Chrome's. JA3 hash: matches Chrome.
The available profiles include HelloChrome_120, HelloFirefox_120, HelloSafari_16_0, HelloIOS_16_0, and a HelloCustom you can populate manually. The utls README is the authoritative list and it's worth a read.
What utls won't do for you
utls only solves the TLS fingerprint. There are other fingerprints to worry about:
| Fingerprint | What it is | Mitigation |
|---|---|---|
| JA3 / JA4 | TLS ClientHello hash | utls |
| HTTP/2 fingerprint | HTTP/2 frame ordering, SETTINGS values | utls (it controls these too via the underlying client) |
| TCP fingerprint | OS-level TCP options | Almost no one checks this for scraping defense |
| Headers fingerprint | Order and casing of HTTP headers | You set them; net/http lowercases by default |
| Behavioural | Click patterns, mouse moves, timing | Only matters with a real browser, headless Chrome land |
utls gets you past JA3/JA4 and HTTP/2 framing. The header-order issue is real (and tls-client below handles it). For behavioural, you need a real browser.
tls-client: the high-level wrapper
github.com/bogdanfinn/tls-client is what most production scrapers reach for. It's built on utls but adds:
- Header order control (HTTP/1.1 header order is also fingerprinted by some vendors).
- A larger set of browser profiles, more frequently updated.
- A request/response API that feels like
http.Client. - A CLI/HTTP-server mode so non-Go callers (Python, Node, anything) can use it.
import (
http "github.com/bogdanfinn/fhttp"
tls_client "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
opts := []tls_client.HttpClientOption{
tls_client.WithTimeoutSeconds(30),
tls_client.WithClientProfile(profiles.Chrome_120),
}
client, _ := tls_client.NewHttpClient(tls_client.NewNoopLogger(), opts...)
req, _ := http.NewRequest(http.MethodGet, "https://practice.scrapingcentral.com/", nil)
req.Header = http.Header{
"user-agent": {"Mozilla/5.0 ..."},
"accept": {"text/html,application/xhtml+xml,*/*"},
"accept-language": {"en-US,en;q=0.9"},
http.HeaderOrderKey: { // ← controls order
"user-agent",
"accept",
"accept-language",
},
}
resp, _ := client.Do(req)
Two things to note:
- The import is
bogdanfinn/fhttp, not standardnet/http.fhttpis a fork that exposes header ordering, which standardnet/httpdeliberately hides. http.HeaderOrderKeycontrols header order. Without it, Go canonicalises headers and emits them alphabetically, which is yet another fingerprint mismatch.
Calling tls-client from Python
If you don't want to rewrite your scraper in Go, run tls-client as an HTTP server (it ships with one) and call it from Python:
# in the tls-client repo
go run cmd/tls-client-server/main.go
# listens on :8080
# Python side
import requests
payload = {
"tlsClientIdentifier": "chrome_120",
"followRedirects": True,
"headers": {
"user-agent": "Mozilla/5.0 ...",
"accept": "text/html,*/*",
"accept-language": "en-US,en;q=0.9",
},
"headerOrder": ["user-agent", "accept", "accept-language"],
"requestUrl": "https://practice.scrapingcentral.com/",
"requestMethod": "GET",
}
resp = requests.post("http://localhost:8080/api/forward", json=payload)
print(resp.json()["status"], resp.json()["body"][:200])
This is the "best of both" workflow many Python scrapers use: Python for orchestration and parsing, Go for the actual TLS-spoofed fetch. You can also call the Go binary from Python directly via subprocess, which is simpler but slower per-request.
Comparing to Python's curl_cffi
For most jobs, Python's curl_cffi is enough:
from curl_cffi import requests
r = requests.get("https://target.com/", impersonate="chrome120")
print(r.status_code)
It works because curl_cffi wraps libcurl-impersonate, which is itself a fork of curl with TLS spoofing. JA3 matches Chrome. Most jobs end here.
You drop to Go's tls-client when:
- You need a profile
curl_cffidoesn't have (newer Chrome version, mobile Safari, etc.). - You need to customise header order at a level
curl_cffidoesn't expose. - You need higher throughput than Python single-process can do (10k+ concurrent fetches).
- A specific anti-bot vendor has caught up to
curl_cffi's profile but not yet totls-client's (this happens; the cat-and-mouse never stops).
For 90% of scrapers, curl_cffi is the right answer. For the 10% where it isn't, knowing Go means you have a path forward instead of being stuck.
Diagnosing "blocked even though my headers look right"
The diagnostic flow when you're getting blocked despite perfect headers:
- Capture the JA3 of your client. Hit a service like
https://tls.peet.ws/api/all(orhttps://tls.browserleaks.com/json) from your scraper. Both echo back your JA3, JA4, headers, etc. - Capture the JA3 of a real Chrome. Open the same service in a real Chrome browser.
- Compare. If JA3 differs, the block is TLS-level. utls / tls-client / curl_cffi will fix it.
- If JA3 matches but you're still blocked, it's headers (order, casing, missing client hints), HTTP/2 framing, or behavioural fingerprinting. Different layer of mitigation.
The vast majority of "I can't tell why I'm blocked" cases are JA3/JA4 mismatches.
When to skip all of this
If you only scrape friendly targets (small sites, your own data, sites that don't deploy Cloudflare/Akamai/DataDome), TLS fingerprinting is irrelevant to you. The Foundations + Static Scraping path is enough. Skip this lesson, save it for the day you hit a wall and the diagnostic flow above says "JA3 mismatch."
If you do hit that wall, this sub-path's investment pays off the day after.
Where to practice
- Clone
utlsand run itsclient.goexample. Modify the profile fromHelloChrome_120toHelloFirefox_120. Hittls.peet.ws/api/alland confirm the JA3 changes. - Run
tls-client's HTTP server example and make a request from Python (as above). - Read the
tls-clientREADME end to end. It's the best single document on this topic. - Read Effective Go: Doc comments just so the Go source code stops feeling foreign when you go to extend these libraries.
Closing the sub-path
You've now seen the five lessons that justify this sub-path's existence:
- GO1: why Go matters for scrapers.
- GO2: enough syntax to read it.
- GO3: the concurrency model.
- GO4: the HTTP client.
- GO5: the TLS fingerprinting payoff.
You're not a Go developer. You don't need to be. You can read tls-client, run a fast concurrent crawler when Python is too slow, and diagnose the specific category of anti-bot failure that needs Go to fix.
Back to scraping. Most of your work is still Python and PHP. Reach for Go when the job actually needs it.