> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# TLS fingerprinting in Go: How to use tls-client
- URL: https://roundproxies.com/blog/tls-client-go/
- Published: 2026-11-05T22:41:05.000Z
- Updated: 2026-11-05T23:36:42.000Z
- Description: Set up tls-client in Go so the handshake, headers and User-Agent all match Chrome 150. Code included.
- Author: Marius Bernard
- Tags: Web Scraping, How to scrape a website, Bypass, #dated-6aed03c956ce2400012694e7

tls-client is the Go library people reach for when a site answers `net/http` with a 403 and a real browser with a 200.

You pick a browser profile, and the TLS handshake and HTTP/2 settings go out the way Chrome or Firefox would send them.

The profile covers the handshake and nothing above it. Leave the headers alone and every request still announces `Go-http-client/2.0` as its User-Agent, a mistake most quickstart snippets carry.

This guide walks through bogdanfinn's library from `go get` to a concurrent, proxied scraper, plus the Python and Node.js bindings.

If the handshake side is new to you, read [what a TLS fingerprint is](https://roundproxies.com/blog/what-is-tls-fingerprint/) first and come back.

## What is tls-client?

**tls-client** is an open-source Go HTTP client, written by bogdanfinn, that copies a real browser's TLS fingerprint and HTTP/2 settings. You choose a profile such as Chrome 150, and each handshake goes out the way that browser's would.

Use it when Go's `net/http` gets 403s a browser doesn't. Python, Node.js and C# can call it through a shared library.

Under the hood it joins two forks bogdanfinn maintains.

`fhttp` is a fork of Go's `net/http` that controls header order, pseudo-header order and HTTP/2 frames.

`utls` builds the ClientHello field by field. The library wraps both behind an interface shaped like `net/http`'s `Client`, with `Do`, `Get` and `Post` plus cookie and proxy setters.

In November 2026 the [GitHub repository](https://github.com/bogdanfinn/tls-client) sits at about 1,900 stars, 308 forks and 50 contributors, and GitHub counts 730 public projects that depend on it.

The newest tagged release is v1.16.0\. Master moves faster than the tags: HTTP/3 transport fixes, a `trust_anchors` extension for the Chrome 150 profiles and a relicense to BSD-3-Clause all landed in the past month.

## tls-client vs curl\_cffi, httpcloak, and net/http

Pick it when the scraper is written in Go and the target fingerprints clients. In Python, curl\_cffi needs less setup; for targets that don't fingerprint, plain `net/http` is fine.

|                          | net/http                 | tls-client                                                  | curl\_cffi                 | httpcloak               |
| ------------------------ | ------------------------ | ----------------------------------------------------------- | -------------------------- | ----------------------- |
| Core language            | Go (stdlib)              | Go (utls + fhttp)                                           | Python on curl-impersonate | Go                      |
| Browser TLS profiles     | None, Go's own handshake | Chrome, Firefox, Safari, Brave, Opera, OkHttp, app profiles | Chrome, Safari, Firefox    | Chrome, Firefox, Safari |
| HTTP/2 fingerprint       | Go's defaults            | Per profile                                                 | Per preset                 | Per preset              |
| HTTP/3                   | No                       | Yes, with protocol racing                                   | Yes                        | Yes                     |
| Header order control     | No                       | Yes, HeaderOrderKey                                         | From the preset            | From the preset         |
| Fills in browser headers | No (Go-http-client)      | No, you send every header                                   | Yes                        | Yes                     |
| Bindings                 | n/a                      | Python, Node.js, C# via FFI                                 | Native Python              | Python, Node.js, C#     |

The row that catches people is the second-to-last. [curl\_cffi](https://roundproxies.com/blog/curl%5Fcffi/) and httpcloak add a User-Agent and client hints that match the preset; bogdanfinn's client sends exactly the headers you give it.

That's more work on day one. It's also why the library is popular for mobile-app APIs, where browser headers would be wrong and you need full control over what goes out.

Older comparison tables list it as HTTP/2-only, our own [httpcloak tutorial](https://roundproxies.com/blog/httpcloak/) included. The current README lists HTTP/3 fingerprints and Chrome-style protocol racing, so treat those tables as stale on that row.

## Prerequisites

- A current Go toolchain. The repo's own tooling builds with Go 1.27; if `go get` complains about the `go` directive, upgrade before anything else.
- Chrome installed locally, to copy header values from DevTools and to save a reference fingerprint in Step 4.
- Optional: an HTTP or SOCKS5 proxy. HTTP/3 through a proxy needs SOCKS5 with UDP support (Step 6).
- Optional: Python 3.10 or newer for the Python section.

## Step 1: Install tls-client and send a first request

Create a module and pull the library in. `go get` also fetches `fhttp` and `utls`, which you'll import directly later.

```bash
mkdir tlsdemo && cd tlsdemo
go mod init tlsdemo
go get github.com/bogdanfinn/tls-client@v1.16.0   # pin a tag; master moves weekly

```

Pinning a tag keeps `go.mod` predictable. The older docs on pkg.go.dev warned that `go get -u` sometimes tangles dependencies; an explicit version avoids that.

The first program fetches `tls.peet.ws/api/all`, an echo service that reports what your client sent. Start `main.go` with the imports:

```go
package main

import (
	"encoding/json"
	"fmt"
	"log"

	tls_client "github.com/bogdanfinn/tls-client"
	"github.com/bogdanfinn/tls-client/profiles"
)

```

Below the imports, a struct picks out the fields from the echo response that matter for the comparison in Step 4:

```go
// peetResponse keeps the tls.peet.ws fields worth comparing to a browser.
type peetResponse struct {
	HTTPVersion string `json:"http_version"`
	UserAgent   string `json:"user_agent"`
	TLS         struct {
		JA4 string `json:"ja4"`
	} `json:"tls"`
	HTTP2 struct {
		AkamaiHash string `json:"akamai_fingerprint_hash"`
	} `json:"http2"`
}

```

The `http2` block only appears when the connection negotiated HTTP/2, which a Chrome profile does against this endpoint. On an HTTP/1.1 connection the field stays empty.

Next, a constructor for the client. Every option uses the functional-options pattern, so you add or drop lines without touching the rest:

```go
func newClient() tls_client.HttpClient {
	client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
		tls_client.WithTimeoutSeconds(30),
		tls_client.WithClientProfile(profiles.Chrome_150),
		tls_client.WithRandomTLSExtensionOrder(), // Chrome shuffles extensions per connection
		tls_client.WithCookieJar(tls_client.NewCookieJar()),
	)
	if err != nil {
		log.Fatal(err)
	}
	return client
}

```

`WithRandomTLSExtensionOrder()` exists because Chrome 107 and later permute their TLS extensions on every connection. A Chrome profile with a fixed extension order is its own small giveaway.

Now the request itself:

```go
func main() {
	client := newClient()
	resp, err := client.Get("https://tls.peet.ws/api/all")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	var fp peetResponse
	if err := json.NewDecoder(resp.Body).Decode(&fp); err != nil {
		log.Fatal(err)
	}
	fmt.Println("protocol:  ", fp.HTTPVersion)
	fmt.Println("user-agent:", fp.UserAgent) // prints Go-http-client/2.0
	fmt.Println("ja4:       ", fp.TLS.JA4)
	fmt.Println("akamai h2: ", fp.HTTP2.AkamaiHash)
}

```

Run it with `go run .`. You'll get an HTTP/2 connection, a Chrome-looking JA4, and a User-Agent line that reads `Go-http-client/2.0`, because the library's documented default is Go's own string whenever you don't set one.

That User-Agent line is what Step 3 fixes. The general `net/http` rules still apply here too: close every body and reuse one client per session, as covered in [our guide to HTTP requests in Go](https://roundproxies.com/blog/requests-go/).

## Step 2: Pick a client profile

Use `profiles.Chrome_150` unless you have a reason not to. It's the library's default profile, and a current desktop Chrome hides in the largest crowd.

The `profiles` package ships these families, each also reachable by a string name in `profiles.MappedTLSClients`:

| Family           | Range in the current profiles.go                                              | Pick it when                                          |
| ---------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
| Chrome           | chrome\_103 to chrome\_152, plus \_PSK variants                               | Default for websites                                  |
| Firefox          | firefox\_102 to firefox\_148                                                  | Your UA and header set are Firefox's                  |
| Safari / iOS     | safari\_16\_0, safari\_ios\_15\_5 to safari\_ios\_26\_0                       | Mobile web, or a site that treats iPhones differently |
| Brave, Opera     | brave\_146, opera\_89 to opera\_91                                            | Rarely; smaller crowds                                |
| OkHttp (Android) | okhttp4\_android\_7 to okhttp4\_android\_13                                   | Android app APIs                                      |
| App profiles     | nike\_ios\_mobile, zalando\_android\_mobile, mesh\_\*, mms\_\*, confirmed\_\* | That specific app's API                               |

The `_PSK` variants add the `pre_shared_key` extension a browser sends when it resumes an earlier TLS session. A returning Chrome visitor resumes; a fresh one doesn't, so PSK profiles make more sense for long sessions against one host.

The version number in the profile has to agree with the version in your User-Agent. A `Chrome_150` handshake that claims `Chrome/146` in its headers is a contradiction any fingerprinting vendor can score.

Loading the profile from config lets you bump versions without a rebuild. Check the map lookup, because a missing key returns an empty profile instead of an error:

```go
name := os.Getenv("TLS_PROFILE") // e.g. "chrome_150_PSK"
profile, ok := profiles.MappedTLSClients[name]
if !ok {
	log.Fatalf("unknown tls-client profile %q", name)
}
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
	tls_client.WithClientProfile(profile),
	tls_client.WithRandomTLSExtensionOrder(),
)
if err != nil {
	log.Fatal(err)
}

```

For app APIs, use the app or OkHttp profile and the app's own headers. A Chrome handshake carrying an Android app's User-Agent is the mismatch you were trying to avoid.

## Step 3: Send Chrome's headers in Chrome's order

Set every header yourself, write the keys in lowercase for HTTP/2, and list them under `http.HeaderOrderKey` in the order Chrome sends them. The profile already handles the pseudo-header order (`:method`, `:authority`, `:scheme`, `:path`).

Header order is a fingerprint of its own. Go stores headers in a map, so without an explicit order list you get whatever order the library writes, which won't be Chrome's.

The lowercase rule and the default User-Agent both come from the [request headers page in the tls-client docs](https://bogdanfinn.gitbook.io/open-source-oasis/readme/request-headers), which is worth a read before you write your own header sets.

Copy the values from your own Chrome: open DevTools, Network tab, click the document request, and read Request Headers. These are the headers Chrome 150 on Windows sends on a top-level page load, in its order:

```go
func chromeNavHeaders() http.Header {
	return http.Header{
		"sec-ch-ua":                 {`"Chromium";v="150", "Google Chrome";v="150", "Not.A/Brand";v="99"`},
		"sec-ch-ua-mobile":          {"?0"},
		"sec-ch-ua-platform":        {`"Windows"`},
		"upgrade-insecure-requests": {"1"},
		"user-agent":                {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"},
		"accept":                    {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},
		"sec-fetch-site":            {"none"},
		"sec-fetch-mode":            {"navigate"},
		"sec-fetch-user":            {"?1"},
		"sec-fetch-dest":            {"document"},
		"accept-encoding":           {"gzip, deflate, br"},
		"accept-language":           {"en-US,en;q=0.9"},
		"priority":                  {"u=0, i"},
		http.HeaderOrderKey: {"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
			"upgrade-insecure-requests", "user-agent", "accept", "sec-fetch-site", "sec-fetch-mode",
			"sec-fetch-user", "sec-fetch-dest", "accept-encoding", "accept-language", "priority"},
	}
}

```

The brand string in `sec-ch-ua` contains a GREASE entry that changes between Chrome releases, so copy it from your browser instead of trusting this block. `sec-ch-ua-platform` and the OS inside the User-Agent have to agree.

Real Chrome also advertises `zstd` in `accept-encoding`. Add it only after you've confirmed your `fhttp` version decodes zstd, since a server that picks an encoding your client can't read hands you a body full of binary noise.

The request helper needs `io` and `http "github.com/bogdanfinn/fhttp"` added to the imports:

```go
func get(client tls_client.HttpClient, target string) ([]byte, error) {
	req, err := http.NewRequest(http.MethodGet, target, nil)
	if err != nil {
		return nil, err
	}
	req.Header = chromeNavHeaders()
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var body io.Reader = resp.Body
	if resp.ProtoMajor == 1 { // you set accept-encoding, so on HTTP/1.1 you decompress
		body = http.DecompressBody(resp)
	}
	return io.ReadAll(body)
}

```

The docs' response-encoding page spells out the split. On HTTP/2 the library decompresses on its own; on HTTP/1.1, once you set `accept-encoding` yourself, decompression becomes your job.

Two follow-ups.

API calls made by page JavaScript carry different headers (`sec-fetch-mode: cors`, `accept: */*`, plus `origin` and `referer`), so build a second header set for those instead of reusing the navigation one.

And if you'd rather not attach headers per request, `tls_client.WithDefaultHeaders(chromeNavHeaders())` applies them to any request that arrives without its own. To send no User-Agent at all, set the value to an empty string.

## Step 4: Diff the fingerprint against your own Chrome

Open `https://tls.peet.ws/api/all` in the Chrome version your profile names, save the JSON as `chrome.json`, then fetch the same URL through your Go client and compare JA4, the Akamai HTTP/2 hash and the User-Agent.

Skip JA3 for this check. With extension order randomized, Chrome's JA3 hash changes on every connection, so two correct clients won't match each other; JA4 sorts the extensions before hashing and stays stable.

A small helper prints each field side by side:

```go
func check(name, want, got string) {
	status := "OK  "
	if want != got {
		status = "DIFF"
	}
	fmt.Printf("%s %-10s want=%s\n                got=%s\n", status, name, want, got)
}

```

The comparison loads your saved browser capture, fetches the live one through the `get` helper from Step 3, and checks three fields (add `"os"` to the imports):

```go
func compare(client tls_client.HttpClient) {
	raw, err := os.ReadFile("chrome.json") // saved from your browser
	if err != nil {
		log.Fatal(err)
	}
	body, err := get(client, "https://tls.peet.ws/api/all")
	if err != nil {
		log.Fatal(err)
	}
	var want, got peetResponse
	if err := json.Unmarshal(raw, &want); err != nil {
		log.Fatal(err)
	}
	if err := json.Unmarshal(body, &got); err != nil {
		log.Fatal(err)
	}
	check("ja4", want.TLS.JA4, got.TLS.JA4)
	check("akamai h2", want.HTTP2.AkamaiHash, got.HTTP2.AkamaiHash)
	check("user-agent", want.UserAgent, got.UserAgent)
}

```

Call `compare(newClient())` from `main` and read the result one row at a time.

A JA4 diff means the profile's Chrome version doesn't match your browser's, so move one of them.

An Akamai diff points at HTTP/2 settings and window sizes, which come from the profile, not from your code.

A User-Agent diff is always yours to fix, in Step 3.

Before comparing, check that both captures report HTTP/2\. If your browser reached the endpoint over HTTP/3, its JSON describes a QUIC connection and won't line up with a TCP one.

I'd run this check after every library upgrade. The default profile moves forward with new releases, and a hardcoded `Chrome/146` User-Agent left over from last quarter is the kind of mismatch this diff catches in a second.

## Step 5: Keep cookies across requests

Pass a jar with `WithCookieJar(tls_client.NewCookieJar())` and the client stores and resends cookies per host, the same way `net/http`'s jar does.

The client also exposes setters for seeding or inspecting cookies by hand (add `"net/url"` to the imports):

```go
u, err := url.Parse("https://example.com")
if err != nil {
	log.Fatal(err)
}
client.SetCookies(u, []*http.Cookie{
	{Name: "session", Value: "abc123", Path: "/"},
})
for _, c := range client.GetCookies(u) {
	fmt.Println(c.Name, c.Value)
}
client.SetCookieJar(tls_client.NewCookieJar()) // wipe every cookie: swap in a fresh jar

```

Swapping the jar is the documented way to clear a session. To drop a single cookie, set it again with a negative `MaxAge`; that works with tls-client's own jar, not with the standard library one.

Seeding matters most when a browser earned the cookies.

Say a real Chrome passed a challenge and got a clearance cookie. That cookie is tied to the fingerprint and IP that earned it.

The Go session reusing it needs the same Chrome version, the same User-Agent and the same exit IP.

When you need to read `Set-Cookie` from a redirect response, turn redirects off on the fly with `client.SetFollowRedirect(false)`, then switch them back on.

## Step 6: Route requests through proxies

Set a proxy with `WithProxyUrl` when you create the client, or `SetProxy` on an existing one. Use `http://user:pass@host:port` or `socks5://user:pass@host:port`.

```go
proxyURL := os.Getenv("PROXY_URL") // http://user:pass@host:port
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
	tls_client.WithClientProfile(profiles.Chrome_150),
	tls_client.WithRandomTLSExtensionOrder(),
	tls_client.WithProxyUrl(proxyURL),
	tls_client.WithTimeoutSeconds(30),
)
if err != nil {
	log.Fatal(err)
}
// Rotating gateway behind one URL: setting the same URL again forces a reconnect,
// and the gateway assigns a new exit IP.
if err := client.SetProxy(proxyURL); err != nil {
	log.Fatal(err)
}

```

That reconnect trick comes from keep-alive. The client holds the connection to the proxy open, so a rotating gateway keeps handing you the same exit IP until you force a new connection.

For sticky sessions, do the opposite: one client per session, one proxy URL per client, and no `SetProxy` calls mid-session. [How rotating proxies work](https://roundproxies.com/blog/rotating-proxies-explained/) covers when each pattern fits.

HTTP/3 changes the rules. QUIC runs over UDP, which an HTTP proxy can't carry, so a client with `WithProtocolRacing()` refuses any proxy scheme other than `socks5://` or `socks5h://`, and the SOCKS5 server has to support UDP ASSOCIATE.

The error is deliberate. Without it, the HTTP/2 leg of the race would go through your proxy while the HTTP/3 leg went out directly with your real IP.

Behind an HTTP proxy, leave racing off; add `WithDisableHttp3()` if you want HTTP/3 ruled out entirely.

Proxy dials and the CONNECT request now respect the client timeout and the request context, so a dead proxy fails the request instead of hanging it.

Providers that read session or geo parameters from CONNECT headers can get them through `WithConnectHeaders`.

Any provider that hands out `user:pass@host:port` URLs plugs into `WithProxyUrl` unchanged; Roundproxies' residential and ISP gateways use that format.

## Step 7: Run requests concurrently and catch blocks

Give each worker its own client, with its own proxy and cookie jar, so a session's IP, cookies and fingerprint stay together. A post-response hook on that client is a cheap place to log blocks.

The per-session constructor adds the hook with `WithPostHook`. It runs after every request, failed ones included:

```go
func newSessionClient(proxy string) (tls_client.HttpClient, error) {
	return tls_client.NewHttpClient(tls_client.NewNoopLogger(),
		tls_client.WithClientProfile(profiles.Chrome_150),
		tls_client.WithRandomTLSExtensionOrder(),
		tls_client.WithProxyUrl(proxy),
		tls_client.WithCookieJar(tls_client.NewCookieJar()),
		tls_client.WithPostHook(func(ctx *tls_client.PostResponseContext) error {
			if ctx.Error == nil && ctx.Response.StatusCode == http.StatusForbidden {
				log.Printf("403 from %s via %s", ctx.Request.URL.Host, proxy)
			}
			return nil
		}),
	)
}

```

Return `nil` from the hook to let the response through. Returning an error stops the remaining hooks, which you rarely want for logging.

Each worker owns one client and pulls URLs from a shared channel. It needs `math/rand`, `sync` and `time` in the imports:

```go
func worker(id int, proxy string, urls <-chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	client, err := newSessionClient(proxy)
	if err != nil {
		log.Printf("worker %d: %v", id, err)
		return
	}
	for u := range urls {
		body, err := get(client, u)
		if err != nil {
			log.Printf("worker %d: %s: %v", id, u, err)
			continue
		}
		log.Printf("worker %d: %s: %d bytes", id, u, len(body))
		time.Sleep(time.Duration(1000+rand.Intn(2000)) * time.Millisecond) // 1 to 3 s
	}
}

```

The pool starts one worker per proxy and feeds them:

```go
func run(proxies, targets []string) {
	urls := make(chan string)
	var wg sync.WaitGroup
	for i, p := range proxies {
		wg.Add(1)
		go worker(i, p, urls, &wg)
	}
	for _, u := range targets {
		urls <- u
	}
	close(urls)
	wg.Wait()
}

```

Concurrency here equals the number of proxies, which is the limit you want: one worker per IP, each paced at one request every one to three seconds. Scale by adding sessions, not by speeding up the ones you have.

On builds newer than v1.16.0, call `client.Close()` when a worker exits. The maintainers added it after finding that HTTP/3 transports hold a UDP socket and goroutines that `CloseIdleConnections()` doesn't release.

## Build a custom profile from a JA3 string

Call `tls_client.GetSpecFactoryFromJa3String` with a JA3 string plus the fields JA3 doesn't record, then wrap the result with `profiles.NewClientProfile`. You only need this for clients the built-in profiles don't cover, such as a niche app's TLS stack.

The first half builds the TLS side. It needs `tls "github.com/bogdanfinn/utls"` and `"github.com/bogdanfinn/fhttp/http2"` in the imports for the next block:

```go
ja3 := "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-10-11-13-16-23-43-51-65281-45-21,29-23-24,0"
sigAlgs := []string{"ECDSAWithP256AndSHA256", "PSSWithSHA256", "PKCS1WithSHA256",
	"ECDSAWithP384AndSHA384", "PSSWithSHA384", "PKCS1WithSHA384", "PSSWithSHA512", "PKCS1WithSHA512"}

specFactory, err := tls_client.GetSpecFactoryFromJa3String(ja3,
	sigAlgs,                          // signature algorithms
	nil,                              // delegated credentials algorithms
	[]string{"GREASE", "1.3", "1.2"}, // supported TLS versions
	[]string{"GREASE", "X25519"},     // key share curves
	[]string{"h2", "http/1.1"},       // ALPN
	[]string{"h2"},                   // ALPS
	nil, nil,                         // ECH candidate cipher suites and payload lengths
	[]string{"brotli"},               // certificate compression
	0,                                // record size limit
	"",                               // trust_anchors payload, only if the JA3 lists 51764
)
if err != nil {
	log.Fatal(err)
}

```

The second half adds the HTTP/2 side and builds the profile. The trailing zero values switch off the HTTP/3 fingerprint, which only matters once racing is on:

```go
settings := map[http2.SettingID]uint32{
	http2.SettingHeaderTableSize:      65536,
	http2.SettingMaxConcurrentStreams: 1000,
	http2.SettingInitialWindowSize:    6291456,
	http2.SettingMaxHeaderListSize:    262144,
}
order := []http2.SettingID{http2.SettingHeaderTableSize, http2.SettingMaxConcurrentStreams,
	http2.SettingInitialWindowSize, http2.SettingMaxHeaderListSize}

custom := profiles.NewClientProfile(
	tls.ClientHelloID{Client: "MyClient", Version: "1", SpecFactory: specFactory},
	settings, order, []string{":method", ":authority", ":scheme", ":path"},
	15663105, nil, nil, 0, false, // connection flow, priorities, header priority, stream ID, allow http
	nil, nil, 0, nil, false, // HTTP/3 settings left empty
)
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), tls_client.WithClientProfile(custom))
if err != nil {
	log.Fatal(err)
}

```

Two gotchas from the docs.

Chromium sends extension `51764` (`trust_anchors`) from Chrome 144 on, and a JA3 containing it fails without the hex payload.

Chrome 150 and later also list ML-DSA signature algorithms, so a Chrome imitation needs `"MLDSA44"`, `"MLDSA65"` or `"MLDSA87"` in `sigAlgs`.

JA3 can't express everything. PSK and ALPS data, for example, need a hand-built `tls.ClientHelloSpec`, which the repo's `example/custom_spec` folder walks through.

## Use tls-client from Python and Node.js

From Python, `pip install tls-client` installs Florian Zager's wrapper around the compiled Go library. Version 2.0.0, released in October 2026, requires Python 3.10 or newer and uses the same profile names as `profiles.MappedTLSClients`.

```python
import os
import tls_client

session = tls_client.Session(
    client_identifier="chrome_150",
    random_tls_extension_order=True,
    header_order=["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
                  "upgrade-insecure-requests", "user-agent", "accept",
                  "accept-encoding", "accept-language"],
)
res = session.get(
    "https://tls.peet.ws/api/all",
    headers={"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                           "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"},
    proxy=os.environ.get("PROXY_URL"),  # None means no proxy
)
print(res.status_code, res.json()["tls"]["ja4"])

```

The header rules from Step 3 carry over unchanged; the Python wrapper doesn't fill in browser headers either. Version 2.0.0 also ships an `AsyncSession` that takes the same arguments, for `asyncio.gather` over many URLs.

Two packaging details trip people up.

Unknown profile names now raise `ValueError` at construction, because the Go library quietly falls back to its default profile for names it doesn't recognise.

The 2.0.0 wheels on the [tls-client PyPI page](https://pypi.org/project/tls-client/) cover glibc Linux, macOS 13+ and Windows, with no musl build.

An Alpine container needs a Debian-based image or the separate `tls-client-python` package, which bundles an Alpine binary.

PyInstaller and Pyarmor builds need the platform's shared library added with `--add-binary`; the PyPI page lists the exact path per platform.

Node.js has wrappers such as `node-tls-client` and `tlsclientwrapper`.

If you call the shared library directly from any language, every response allocates memory on the Go side: pass each response's `id` to `freeMemory()`, and destroy sessions you're done with so their HTTP/3 sockets close.

## Troubleshooting

### "x509: certificate signed by unknown authority"

**Why:** The server's certificate chain doesn't verify, usually a self-signed cert or a debugging proxy such as Charles sitting in the middle.

**Fix:** For local debugging only, `WithCharlesProxy("", "")` points the client at Charles on 127.0.0.1:8888, and `WithInsecureSkipVerify()` accepts its certificate. Never ship skip-verify to production.

### "stream error: stream ID 3; PROTOCOL\_ERROR"

**Why:** Most often a hand-set `content-length` header that doesn't match the body, which an HTTP/2 server rejects.

**Fix:** Delete the `content-length` header. The client sets it from the body for you.

### The response body is binary garbage

**Why:** You set `accept-encoding` yourself on an HTTP/1.1 connection, so the body arrived compressed, or the server picked an encoding your `fhttp` build can't decode.

**Fix:** Pass HTTP/1.1 responses through `http.DecompressBody(resp)` as in Step 3, and drop `zstd` from `accept-encoding` unless you've confirmed support.

### The client fails to build after you enable protocol racing

**Why:** Some options exclude each other: racing with `WithDisableHttp3()` or `WithForceHttp1()`, racing behind a non-SOCKS5 proxy, disabling both IPv4 and IPv6, or certificate pinning combined with skip-verify.

**Fix:** Read the error, drop one side of the conflict, and use a SOCKS5 proxy with UDP support if you need racing behind a proxy.

### 403 even though the fingerprint diff is clean

**Why:** The block comes from a layer above TLS: IP reputation, a JavaScript challenge, missing cookies, or request pacing.

**Fix:** Log the response body and look for the vendor's challenge markup. If it's a challenge page, no profile change will help; see the next section.

## Where tls-client stops helping

tls-client fixes the network layer and nothing that happens inside a browser. JavaScript challenges, sensor scripts, canvas checks and IP reputation are all out of its reach.

The project README advertises a paid token API for Akamai, DataDome, Kasada and Incapsula. You don't need it.

The self-built route is a real browser that passes the challenge once, followed by a cookie handoff into a Go session with the same Chrome version, User-Agent and exit IP (Step 5).

Pacing counts for as much as the fingerprint. A flawless Chrome handshake sending ten requests a second from one residential IP still looks like a script, because no person browses that fast.

And a bad IP stays bad. If the exit address is on a datacenter range the target blocks, no fingerprint changes that; fix the IP first.

## FAQ

### Is tls-client free to use?

Yes. tls-client has been open source under the BSD-3-Clause license since a relicense in late October 2026; older listings still show BSD-4-Clause. BSD-3-Clause allows commercial use as long as you keep the copyright notice.

### Does tls-client set the User-Agent for me?

No. Without a User-Agent header, tls-client sends Go's defaults: Go-http-client/1.1 on HTTP/1.1, Go-http-client/2.0 on HTTP/2, and quic-go HTTP/3 on HTTP/3.

Set the User-Agent and the client-hint headers yourself, matching the profile's browser version.

### Does tls-client support HTTP/3?

Yes. The current release has HTTP/3 fingerprints per profile and Chrome-style protocol racing through WithProtocolRacing(), which starts HTTP/3 and HTTP/2 in parallel and caches the winner per host.

Behind a proxy, racing requires SOCKS5 with UDP ASSOCIATE.

### Is tls-client faster than curl\_cffi?

In Python, curl\_cffi has the edge: a January 2025 benchmark on Medium measured it about 20 to 30 percent faster over 50 sequential requests to httpbin.

In Go there's no FFI hop, so tls-client runs at native speed, but measure against your own targets before deciding.

### Can tls-client get past Cloudflare?

It gets past Cloudflare's TLS and HTTP/2 fingerprint checks, which are what block Go's net/http on many sites. It can't run a managed JavaScript challenge or Turnstile.

For those, earn the clearance cookie in a real browser and reuse it in a tls-client session with the same fingerprint and IP.

## Wrapping up

You now have a Go client that sends a Chrome 150 handshake and Chrome's headers in Chrome's order, keeps cookies and IPs together per session, and proves all of that against your own browser.

The habit to keep is the Step 4 diff. Profiles, User-Agents and header values drift every few Chrome releases, and the diff tells you which of the three moved.

Next, point the Step 7 pool at a small batch of real URLs with two proxies and watch the 403 log. If blocks persist with a clean diff, the problem has moved above TLS.