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 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 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 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 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 getcomplains about thegodirective, 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.
mkdir tlsdemo && cd tlsdemo
go mod init tlsdemo
go get github.com/bogdanfinn/[email protected] # 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:
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:
// 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:
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:
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.
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:
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, 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:
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:
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:
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):
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):
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.
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 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:
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:
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:
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:
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:
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.
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 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.