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.
Quiz, check your understanding
Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.