Go's net/http is the default HTTP client for millions of Go developers, but if you've ever pointed it at a Cloudflare-protected endpoint, you know the pain: 403 Forbidden, endless CAPTCHA challenges, or silent connection resets. The culprit isn't your code — it's your TLS fingerprint. Fixing Go's net/http TLS fingerprint means replacing the crypto/tls ClientHello with one that looks like real Chrome, then routing through clean residential IPs so both the handshake and the network identity pass as human.
Whether you're building a web scraper, a SERP tracking pipeline, or an authorized security research tool, the TLS fingerprint of your Go client is the first thing modern WAFs inspect. In this guide, we'll break down exactly why Go's TLS fingerprint differs from Chrome, how to capture and inspect your JA3/JA4 hash, and how to patch your client with uTLS — all while routing through residential proxies on gate.proxyhat.com so your IP reputation matches your spoofed browser profile.
Fixing Go's net/http TLS Fingerprint: Why Your Client Gets Blocked
Go's crypto/tls package emits a predictable, static ClientHello. Every binary built with the same Go version sends identical cipher suite ordering, the same set of TLS extensions, and zero GREASE values. WAFs like Cloudflare and Akamai compute JA3 and JA4 hashes from these fields and flag anything that doesn't match a known browser profile within 5ms.
The problem is fundamental: Go's TLS stack was designed for interoperability, not mimicry. It negotiates the strongest cipher first, lists extensions in a fixed order, and doesn't include the random "junk" GREASE entries that Chrome and Firefox sprinkle into their handshakes. The result is a JA3 hash that's uniquely Go — and instantly recognizable to any modern WAF.
According to RFC 8446 (TLS 1.3), the ClientHello message includes cipher suites, extensions, supported_groups, signature_algorithms, and other parameters that uniquely identify the client implementation. WAFs fingerprint these parameters and compare them against known browser profiles. Go's profile stands out because:
- Fixed cipher order: Go lists ciphers in a deterministic, security-prioritized order. Chrome randomizes cipher order with GREASE values (e.g., 0x0A0A, 0x1A1A, 0x2A2A) inserted at specific positions per RFC 8701.
- Missing GREASE: GREASE values are dummy entries that prevent middlebox ossification. Chrome includes them in cipher suites, extensions, supported_groups, and key_share. Go includes none.
- Extension set differs: Go sends a minimal set of 14 extensions. Chrome sends 18+ extensions including
application_settings,delegated_credentials,encrypted_client_hello, andcompress_certificate. - No ALPN by default: Go's
net/httpdoesn't set ALPN protocols unless explicitly configured. Chrome always sendsh2,http/1.1. - Static key_share: Go sends a single X25519 key share. Chrome sends GREASE + X25519 + P-256 (3 entries with GREASE).
The net effect: a Go HTTP client has a JA3 hash like 771,4865-4866-4867-49195-49199-... with a fixed, recognizable ordering. Chrome's JA3 includes GREASE values like 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-... with randomized GREASE entries. WAFs can distinguish them in under 5ms.
Capturing Your JA3 and JA4 Fingerprint
Before fixing anything, capture your current fingerprint. Point your Go client at tls.peet.ws and inspect the result:
curl -s https://tls.peet.ws/api/all | python3 -m json.tool | grep -E '"ja3|ja4'
Then compare with Chrome's fingerprint by visiting the same URL in a real Chrome browser. You'll see completely different JA3 and JA4 hashes. The Go default produces a JA3 like:
"ja3": "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"
While Chrome 120 produces something like:
"ja3": "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-10-11,29-23-24-25,0"
The differences are subtle but decisive. Chrome includes a GREASE cipher (0x0A0A) at the start, additional extensions (10, 11), and an extra key_share group (25 = P-256). WAFs check all of these.
JA4 is the newer fingerprinting standard that addresses some of JA3's weaknesses. JA4 separates the fingerprint into components (application, cipher list, extensions, signature algorithms) and sorts them, making it harder to evade by reordering. We'll cover JA4 in detail later — for now, know that both JA3 and JA4 must match a real browser to pass modern WAFs.
ClientHello Signals: Go vs Real Chrome
Let's break down the specific ClientHello fields that differ between Go's crypto/tls and real Chrome. These are the signals that JA3/JA4 fingerprints encode:
| Signal | Go net/http (crypto/tls) | Chrome 120 |
|---|---|---|
| Cipher suites | 13 fixed ciphers, no GREASE, deterministic order | 17+ ciphers with GREASE (0x0A0A, 0x1A1A), Chrome-specific ordering |
| Extensions | 14 extensions, fixed order, no GREASE | 18+ extensions including application_settings, delegated_credentials, encrypted_client_hello, GREASE extension (0x4A4A) |
| supported_groups | X25519, P-256, P-384 (3 groups, no GREASE) | GREASE + X25519, P-256, P-384 (4+ groups with GREASE prefix) |
| signature_algorithms | Fixed list: ecdsa_secp256r1, rsa_pss_rsae, rsa_pkcs1, ed25519 | Chrome-specific order with ecdsa_secp256r1 first, includes rsa_pss schemes |
| ALPN | Empty unless explicitly set | h2, http/1.1 (always present) |
| key_share | Single X25519 entry | GREASE + X25519 + P-256 (3 entries with GREASE) |
| GREASE values | None anywhere | Present in ciphers, extensions, groups, key_share, versions |
Each of these fields contributes to the JA3/JA4 hash. When Go sends 13 cipher suites in a fixed order with no GREASE, the resulting hash is completely different from Chrome's 17-cipher, GREASE-laden ClientHello. WAFs don't need to inspect every field — the hash alone is enough to flag you.
Replacing the Handshake with uTLS
The refraction-networking/utls library is the standard solution for Go TLS fingerprint mimicry. It replaces Go's crypto/tls handshake with a fork that can emit arbitrary ClientHello messages, including exact replicas of Chrome, Firefox, and Safari.
The key API is utls.UClient, which wraps a raw net.Conn and performs a TLS handshake using a specified fingerprint profile. The most commonly used profiles are:
utls.HelloChrome_Auto— automatically selects the latest Chrome fingerprint. This is the recommended default for most use cases.utls.HelloSafari_16_0— pins to Safari 16.0's fingerprint. Useful when you need to set your TLS fingerprint to Safari specifically, or when Chrome's fingerprint is overrepresented in your traffic pattern.utls.HelloFirefox_120— pins to Firefox 120's fingerprint.utls.HelloRandomized— randomizes the fingerprint on each connection. Higher entropy but less predictable behavior.
Here's how to wire uTLS into an http.Transport using DialTLSContext:
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"time"
utls "github.com/refraction-networking/utls"
)
func main() {
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, _ := net.SplitHostPort(addr)
// Dial the raw TCP connection
rawConn, err := (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
// Wrap with uTLS Chrome fingerprint
uConn := utls.UClient(rawConn, &utls.Config{
ServerName: host,
}, utls.HelloChrome_Auto)
// Perform the TLS handshake
if err := uConn.HandshakeContext(ctx); err != nil {
rawConn.Close()
return nil, err
}
return uConn, nil
},
}
client := &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
}
resp, err := client.Get("https://tls.peet.ws/api/all")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
Run this against tls.peet.ws/api/all and compare the JA3/JA4 output to what you got with plain net/http. The uTLS version should produce a Chrome-matching fingerprint. If you need Safari instead, swap utls.HelloChrome_Auto for utls.HelloSafari_16_0 — the rest of the code stays the same.
One critical detail: utls.HelloChrome_Auto tracks the latest Chrome fingerprint bundled with your uTLS version. You should update the uTLS dependency regularly (at least every 2-3 months) to stay current with Chrome releases. A stale fingerprint is almost as bad as Go's default.
Higher-Level Alternatives: CycleTLS and azuretls-client
If you don't want to manage DialTLSContext manually, two higher-level libraries wrap uTLS with friendlier APIs:
CycleTLS
CycleTLS lets you specify a JA3 string directly, giving you full control over the fingerprint without importing uTLS internals:
package main
import (
"log"
"github.com/Danny-Dasilva/CycleTLS/cycletls"
)
func main() {
client := cycletls.Init()
resp, err := client.Do("https://tls.peet.ws/api/all", cycletls.Options{
Method: "GET",
Ja3: "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",
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Proxy: "http://user-country-US-session-abc123:pass@gate.proxyhat.com:8080",
}, "GET")
if err != nil {
log.Fatal(err)
}
log.Println(resp.Status)
}
azuretls-client
azuretls-client provides a full HTTP client with built-in TLS fingerprinting, HTTP/2 support, and proxy integration:
package main
import (
"fmt"
"log"
"github.com/Noooste/azuretls-client"
)
func main() {
client := azuretls.NewClient()
client.SetProxy("http://user-country-US-session-abc123:pass@gate.proxyhat.com:8080")
resp, err := client.Get("https://tls.peet.ws/api/all")
if err != nil {
log.Fatal(err)
}
fmt.Println("Status:", resp.StatusCode)
}
Here's how the three approaches compare:
| Feature | uTLS (raw) | CycleTLS | azuretls-client |
|---|---|---|---|
| TLS fingerprint control | Full — any uTLS profile | Full — custom JA3 string | Automatic — Chrome by default |
| HTTP/2 support | Manual (ALPN negotiation) | Built-in | Built-in |
| Proxy support | Manual (DialTLSContext) | Built-in | Built-in |
| Maintenance burden | High — you manage everything | Medium — JA3 string needs updates | Low — automatic updates |
| Best for | Custom setups, security research | Quick prototyping, JA3 control | Production scraping at scale |
Routing uTLS Through Residential Proxies
TLS fingerprint mimicry alone is not enough. Even with a perfect Chrome JA3/JA4, if your requests originate from a datacenter IP, WAFs flag the mismatch: Chrome fingerprint + datacenter IP = bot. The fingerprint says "Chrome on a home network" but the IP says "AWS us-east-1." This inconsistency is itself a detection signal.
The solution is to route your uTLS client through residential proxies so both the TLS fingerprint and the IP reputation look human. ProxyHat's residential proxy network provides clean, ISP-assigned IPs that match the browser profile you're presenting. See our pricing for plan details.
Here's a complete example that combines uTLS with ProxyHat residential proxies, using the -country-US-session-abc123 flags for geo-targeting and sticky sessions:
package main
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
utls "github.com/refraction-networking/utls"
)
func main() {
proxyURL, _ := url.Parse("http://user-country-US-session-abc123:pass@gate.proxyhat.com:8080")
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// 1. Connect to the ProxyHat gateway
proxyConn, err := (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext(ctx, "tcp", proxyURL.Host)
if err != nil {
return nil, err
}
// 2. Send CONNECT with proxy authentication
auth := base64.StdEncoding.EncodeToString(
[]byte(proxyURL.User.String()))
fmt.Fprintf(proxyConn,
"CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: Basic %s\r\n\r\n",
addr, addr, auth)
// 3. Read CONNECT response
br := bufio.NewReader(proxyConn)
resp, err := http.ReadResponse(br, nil)
if err != nil || resp.StatusCode != 200 {
proxyConn.Close()
return nil, fmt.Errorf("proxy CONNECT failed")
}
resp.Body.Close()
// 4. Wrap the tunnel with uTLS Chrome fingerprint
host, _, _ := net.SplitHostPort(addr)
uConn := utls.UClient(proxyConn, &utls.Config{
ServerName: host,
}, utls.HelloChrome_Auto)
if err := uConn.HandshakeContext(ctx); err != nil {
proxyConn.Close()
return nil, err
}
return uConn, nil
},
}
client := &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
}
resp, err := client.Get("https://tls.peet.ws/api/all")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
This example does four things in sequence:
- Dials the ProxyHat gateway at
gate.proxyhat.com:8080 - Sends an HTTP CONNECT request with Basic auth, including the
user-country-US-session-abc123flags for US geo-targeting and a sticky session - Reads the 200 response, confirming the tunnel is established
- Wraps the tunneled connection with uTLS
HelloChrome_Autoand performs the TLS handshake
The result: the target server sees a Chrome TLS fingerprint coming from a US residential IP. Both signals are consistent. The WAF has no reason to flag the request.
For SOCKS5, swap the proxy URL to socks5://user-country-US-session-abc123:pass@gate.proxyhat.com:1080 and adjust the dialer accordingly. See the ProxyHat documentation for full proxy configuration options, and check available locations for geo-targeting options beyond the US.
JA4 Supersedes JA3: Keeping Parrots Current
JA3 has been the dominant TLS fingerprinting standard since 2017, but it has weaknesses. JA3 hashes the ClientHello in raw byte order, which means reordering ciphers or extensions produces a different hash — even if the actual capabilities are identical. This makes JA3 fragile and easy to evade by shuffling fields.
JA4 fixes this by sorting components before hashing, producing a more stable fingerprint that's harder to evade. JA4 also separates the fingerprint into a structured format (e.g., t13d1516h2_8daaf6152771_b186095e22b6) where each segment encodes different information: TLS version, cipher count, extension count, ALPN, and signature algorithms.
For Go developers, the practical implications are:
- uTLS
HelloChrome_Autocovers both JA3 and JA4 — since it replays Chrome's exact ClientHello, both fingerprints match. - CycleTLS JA3 strings only cover JA3 — if the target WAF uses JA4, you need a JA4-compatible approach. CycleTLS has added JA4 support in recent versions; check the CycleTLS repo for updates.
- Keep uTLS updated — Chrome changes its ClientHello every 4-6 weeks. A stale uTLS profile produces a valid but outdated fingerprint that sophisticated WAFs can detect. Update your uTLS dependency at least every 2-3 months.
The best practice is to test your fingerprint against tls.peet.ws/api/all before deploying, and re-test whenever you update uTLS or change your proxy configuration. If the JA3 or JA4 doesn't match the latest Chrome, update uTLS before sending production traffic.
Common Mistakes and Edge Cases
Even with uTLS and residential proxies, several common mistakes can get you blocked:
- Forgetting HTTP/2: Chrome always negotiates HTTP/2 via ALPN. If your uTLS client falls back to HTTP/1.1, the WAF sees Chrome's TLS fingerprint with HTTP/1.1 traffic — a dead giveaway. Make sure your transport supports HTTP/2 or use a library like azuretls that handles it automatically.
- Mismatched User-Agent: If you send
HelloChrome_Autobut set a Firefox User-Agent header, the WAF sees Chrome's TLS fingerprint with a Firefox UA string. Always match the UA to the TLS profile. - Sticky sessions too sticky: Using the same
session-abc123for 100+ requests from the same IP looks like a bot even on a residential proxy. Rotate sessions every 20-50 requests, or use per-request rotation for high-volume scraping. - Ignoring HTTP header order: Chrome sends headers in a specific order (Host, Connection, sec-ch-ua, sec-ch-ua-mobile, User-Agent, Accept, ...). Go's
net/httpsends them in map iteration order, which is random. Use a library that preserves header order, or set headers manually in the correct sequence. - Datacenter IP + Chrome fingerprint: This is the most common mistake. Even with a perfect uTLS fingerprint, a datacenter IP instantly flags the request as a bot. Always route through residential proxies when the TLS fingerprint claims to be a browser.
Key Takeaways
Go's
crypto/tlsproduces a static, identifiable ClientHello that WAFs flag in under 5ms. Fixing it requires two layers: uTLS for the TLS fingerprint and residential proxies for the network identity.
- Go's
net/httpTLS fingerprint differs from Chrome in cipher order, GREASE values, extensions, supported_groups, key_share, and ALPN — all detectable via JA3/JA4. utls.HelloChrome_Autoreplaces Go's handshake with a Chrome-accurate ClientHello. Useutls.HelloSafari_16_0for Safari-specific fingerprinting.- CycleTLS and azuretls-client offer higher-level APIs if you don't want to manage
DialTLSContextmanually. - TLS fingerprint mimicry without residential proxies is ineffective — datacenter IPs contradict the browser fingerprint. Route through
gate.proxyhat.com:8080with geo-targeting flags likeuser-country-US-session-abc123. - JA4 is superseding JA3. Keep uTLS updated every 2-3 months to track Chrome's evolving ClientHello.
- Test your fingerprint against
tls.peet.ws/api/allbefore deploying and after every update.






