Scraping Central is reader-supported. When you buy through links on our site, we may earn an affiliate commission.

GO5advanced8 min read

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:

  1. TLS version offered (e.g. 1.3, 1.2).
  2. A list of cipher suites in the client's preference order.
  3. A list of TLS extensions (SNI, ALPN, key share, supported groups, etc.).
  4. 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 standard net/http. fhttp is a fork that exposes header ordering, which standard net/http deliberately hides.
  • http.HeaderOrderKey controls 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_cffi doesn't have (newer Chrome version, mobile Safari, etc.).
  • You need to customise header order at a level curl_cffi doesn'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 to tls-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:

  1. Capture the JA3 of your client. Hit a service like https://tls.peet.ws/api/all (or https://tls.browserleaks.com/json) from your scraper. Both echo back your JA3, JA4, headers, etc.
  2. Capture the JA3 of a real Chrome. Open the same service in a real Chrome browser.
  3. Compare. If JA3 differs, the block is TLS-level. utls / tls-client / curl_cffi will fix it.
  4. 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 utls and run its client.go example. Modify the profile from HelloChrome_120 to HelloFirefox_120. Hit tls.peet.ws/api/all and confirm the JA3 changes.
  • Run tls-client's HTTP server example and make a request from Python (as above).
  • Read the tls-client README 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.

TLS Fingerprinting with utls & tls-client1 / 6

What is a TLS fingerprint (in the JA3/JA4 sense)?

Score so far: 0 / 0