All posts

TLS Fingerprinting vs Antibots: Bypasses with tls-client

You set the right User-Agent. You copied every header from DevTools in the right order. You replayed the exact cookies. And you still get a 403 — or worse, a silent 200 with poisoned data.

The reason is that the server decided you were a bot during the TLS handshake, before it ever parsed your HTTP request. Modern anti-bot systems read the shape of your ClientHello and your HTTP/2 frames and compare it against a database of known browser signatures. A stock net/http, requests, or axios client has a signature that no real browser on earth produces.

This post breaks down what actually gets fingerprinted, why standard clients fail, and how bogdanfinn/tls-client lets you forge a byte-accurate browser handshake.

What actually gets fingerprinted

When a TLS client opens a connection, the very first message it sends is the ClientHello. It is unencrypted and it is enormously expressive. Anti-bot vendors care about these fields:

  • TLS version — the supported_versions extension (TLS 1.2 vs 1.3)
  • Cipher suites — the exact list and order
  • Extensions — which ones, and historically their order
  • Supported groups — elliptic curves (x25519, secp256r1, ...)
  • EC point formats
  • Signature algorithms
  • ALPN — h2, http/1.1
  • GREASE values — random reserved values browsers inject to keep the ecosystem honest

These get hashed into a single string. That string is your fingerprint.

JA3 and JA4

JA3 concatenates five ClientHello fields and MD5-hashes them. The pre-hash string looks like this:

771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0
TLSVersion , CipherSuites , Extensions , EllipticCurves , ECPointFormats

JA3 worked well for years, but it has a fatal weakness against modern Chrome: since Chrome 110, the browser randomizes the order of its TLS extensions on every handshake. That means a real Chrome produces a different JA3 on every request. Naive JA3 blocklists either break or get bypassed trivially.

JA4 (by FoxIO) was designed to fix this. It sorts the cipher and extension lists before hashing, so randomization no longer changes the value, and it's human-readable:

t13d1516h2_8daaf6152771_b186095e22b6
│ │ │  │ │       │            └─ hash of extensions + sig algorithms
│ │ │  │ │       └─ hash of sorted cipher suites
│ │ │  │ └─ ALPN first value (h2)
│ │ │  └─ extension count
│ │ └─ cipher suite count (2-digit counts)
│ └─ SNI present (d = to domain)
└─ TLS 1.3 over TCP

Cloudflare, DataDome and others now fingerprint on JA4 (and the wider JA4+ suite: JA4H for HTTP, JA4T for TCP) precisely because it survives Chrome's randomization.

HTTP/2 fingerprinting (the Akamai fingerprint)

TLS isn't the only layer. Once the connection is up, HTTP/2 has its own tells. Akamai's well-known format captures four things:

1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
│                              │        │ └─ pseudo-header order
│                              │        └─ PRIORITY frames
│                              └─ WINDOW_UPDATE increment
└─ SETTINGS frame parameters

The pseudo-header order — :method, :authority, :scheme, :path — is m,a,s,p in Chrome and differs in other clients. The SETTINGS values, the initial window update, header priority, even the order of your regular headers are all part of the signature. Go's net/http and Python's hpack produce orderings that scream "not a browser."

Why your client gets blocked

Here is the uncomfortable truth, side by side:

ClientTLS stackFingerprint reality
Chrome 120BoringSSLThe reference signature everyone whitelists
Go net/httpcrypto/tlsDistinct cipher order, no GREASE, no extension permutation
Python requestsOpenSSLOpenSSL default suite order — unmistakably not a browser
Node httpsOpenSSLSame problem, plus different HTTP/2 settings
curlOpenSSL/variousOne of the most blocklisted JA3s in existence

You cannot fix this from userland in these libraries. The cipher list, extension set, and GREASE injection are baked into the TLS library itself. Changing headers does nothing — the verdict is already in before the first header is read.

This is the whole reason utls exists: it's a fork of Go's crypto/tls that lets you hand-craft the ClientHello. tls-client builds the ergonomic, batteries-included layer on top of it.

Enter tls-client

bogdanfinn/tls-client is a Go HTTP client that produces browser-accurate TLS and HTTP/2 fingerprints. It stitches together two forks:

  • bogdanfinn/utls — controls the ClientHello (TLS layer)
  • bogdanfinn/fhttp — a fork of net/http that controls header order, pseudo-header order, and HTTP/2 SETTINGS

It ships with a library of pre-built profiles (Chrome_120, Firefox_117, Safari_16_0, Okhttp4Android13, and dozens more), each one a complete bundle of TLS spec + HTTP/2 settings + header ordering captured from the real client.

Example 1: impersonate Chrome

package main

import (
	"fmt"
	"io"
	"log"

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

func main() {
	jar := tls_client.NewCookieJar()
	options := []tls_client.HttpClientOption{
		tls_client.WithTimeoutSeconds(30),
		tls_client.WithClientProfile(profiles.Chrome_120),
		tls_client.WithCookieJar(jar),
		// Chrome 110+ permutes its extensions — match that behaviour:
		tls_client.WithRandomTLSExtensionOrder(),
	}

	client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...)
	if err != nil {
		log.Fatal(err)
	}

	req, err := http.NewRequest(http.MethodGet, "https://tls.peet.ws/api/all", nil)
	if err != nil {
		log.Fatal(err)
	}

	// fhttp lets you pin the exact header order with HeaderOrderKey
	req.Header = http.Header{
		"accept":          {"*/*"},
		"accept-language": {"en-US,en;q=0.9"},
		"user-agent":      {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
		http.HeaderOrderKey: {
			"accept",
			"accept-language",
			"user-agent",
		},
	}

	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Println(string(body))
}

Example 2: verify the forgery

The endpoint above — tls.peet.ws/api/all, built by the same crew — echoes back exactly what the server saw. Run the code and compare the JSON to a real Chrome hitting the same URL:

{
  "ja3": "771,4865-4866-4867-49195-...",
  "ja3_hash": "cd08e31494f9531f560d64c695473da9",
  "ja4": "t13d1516h2_8daaf6152771_b186095e22b6",
  "akamai_fingerprint": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
  "akamai_fingerprint_hash": "52d84b11737d980aef856699f885ca86",
  "user_agent": "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36"
}

If the ja3_hash, ja4, and akamai_fingerprint_hash match a genuine browser, you are — at the transport layer — indistinguishable from Chrome. This is the part that stock clients can never reproduce. Other good verification endpoints: browserleaks.com/tls and Scrapfly's JA3 tool.

Example 3: a hand-rolled JA3 / custom profile

Sometimes you need a fingerprint that isn't in the profile library — a specific mobile app, an older browser, a target that whitelists one exact JA3. You can build a profile from scratch:

import (
	tls "github.com/bogdanfinn/utls"
	http2 "github.com/bogdanfinn/fhttp/http2"
	"github.com/bogdanfinn/tls-client/profiles"
)

customProfile := profiles.NewClientProfile(
	tls.ClientHelloID{
		Client:  "MyCustomClient",
		Version: "1",
		Seed:    nil,
		SpecFactory: func() (tls.ClientHelloSpec, error) {
			return tls.ClientHelloSpec{
				CipherSuites: []uint16{
					tls.GREASE_PLACEHOLDER,
					tls.TLS_AES_128_GCM_SHA256,
					tls.TLS_AES_256_GCM_SHA384,
					tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
					// ... exactly the suites your target expects
				},
				Extensions: []tls.TLSExtension{
					&tls.UtlsGREASEExtension{},
					&tls.SNIExtension{},
					&tls.SupportedCurvesExtension{Curves: []tls.CurveID{
						tls.GREASE_PLACEHOLDER, tls.X25519, tls.CurveP256,
					}},
					&tls.ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}},
					// ... order matters when you are NOT randomizing
				},
			}, nil
		},
	},
	map[http2.SettingID]uint32{ // HTTP/2 SETTINGS
		http2.SettingHeaderTableSize:   65536,
		http2.SettingInitialWindowSize: 6291456,
	},
	[]http2.SettingID{ // SETTINGS order
		http2.SettingHeaderTableSize,
		http2.SettingInitialWindowSize,
	},
	[]string{":method", ":authority", ":scheme", ":path"}, // pseudo-header order
	15663105, // connection flow / window update
	nil,
	nil,
)

options := []tls_client.HttpClientOption{
	tls_client.WithClientProfile(customProfile),
}

You can also feed a raw JA3 string directly via tls_client.WithCustomTlsClient / the JA3-string helpers when you only need the TLS layer and don't want to enumerate every extension by hand.

Not just Go: the bindings

Most scraping stacks aren't Go. tls-client compiles to a shared C library (.so / .dll / .dylib) exposed over CFFI, plus a standalone HTTP API server. That gives you first-class wrappers:

  • Python — tls-client on PyPI
  • Node — community wrappers around the same shared lib
  • API mode — run it as a sidecar and POST your request spec as JSON
import tls_client

session = tls_client.Session(
    client_identifier="chrome_120",
    random_tls_extension_order=True,
)

resp = session.get(
    "https://tls.peet.ws/api/all",
    headers={
        "user-agent": "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36",
        "accept": "*/*",
    },
)
print(resp.json()["ja3_hash"])

Same engine, same fingerprint, from Python.

Where the arms race goes next

A perfect TLS fingerprint is necessary but not sufficient. It gets you past the cheap, fast layer of filtering — and that layer blocks the overwhelming majority of unsophisticated bots. But the serious vendors stack more on top:

  • JA4 over JA3 — Cloudflare and DataDome moved to JA4 specifically because it defeats lazy randomization tricks. Make sure your library produces a consistent, browser-correct JA4, not just a passing JA3.
  • TLS ↔ User-Agent coherence — claiming chrome_120 in your UA while presenting a Firefox JA3 is an instant tell. The profile and the header must agree.
  • HTTP/2 coherence — the Akamai fingerprint has to match the same browser version as your TLS profile.
  • JavaScript challenges — Cloudflare Turnstile, Kasada, and Akamai's sensor data run in a real JS runtime. No raw HTTP client solves these; that's a separate (and harder) problem.
  • Behavioral / proof-of-work — timing, mouse entropy, and PoW puzzles live above the network layer entirely.

The honest framing: tls-client wins the transport-layer round decisively. It does not, by itself, beat a full Cloudflare managed challenge or a Kasada PoW — that fight moves up into the browser, and we cover it in reverse engineering anti-bot JavaScript. Treat the handshake as the foundation — get it perfect first, because everything above it is wasted effort if the server already flagged you on byte one.

Key takeaways

  • Anti-bots fingerprint you before they read your HTTP request — JA3, JA4 and the HTTP/2 Akamai fingerprint are decided in the handshake.
  • Stock net/http, requests, axios, and curl have fingerprints no browser produces, and you can't fix that from userland in those libraries.
  • tls-client (on utls + fhttp) forges a byte-accurate ClientHello, HTTP/2 SETTINGS, and header order — with ready-made browser profiles and full custom control.
  • Always verify against tls.peet.ws/api/all and confirm your JA3, JA4, and Akamai hashes match a real browser of the same version.
  • TLS is the floor, not the ceiling. Keep your UA, TLS, and HTTP/2 layers coherent, and remember JS challenges are a different battle.

Beating anti-bot defenses is core to the reverse engineering work we do as a service. Working on automation that keeps tripping anti-bot defenses? Get in touch.

Need the collection system built around this? Explore our web scraping and automation service.

About the authors

  • @blik2bankomatGitHub

    Co-Founder & Engineer at status403

    Builds and reverse engineers software systems, from low-level protocols and high-throughput network automation to production applications and infrastructure.

  • Co-Founder & Engineer at status403

    Builds and reverse engineers software systems, from low-level protocols and high-throughput network automation to production applications and infrastructure.