Fixing Go's net/http TLS Fingerprint: Pass WAF Detection with uTLS

Go's crypto/tls emits a static, un-Chrome-like ClientHello that WAFs flag instantly. Learn to replace it with uTLS, route through residential proxies, and pass JA3/JA4 checks.

Fixing Go's net/http TLS Fingerprint: Pass WAF Detection with uTLS
In this article

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, and compress_certificate.
  • No ALPN by default: Go's net/http doesn't set ALPN protocols unless explicitly configured. Chrome always sends h2,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:

  1. Dials the ProxyHat gateway at gate.proxyhat.com:8080
  2. Sends an HTTP CONNECT request with Basic auth, including the user-country-US-session-abc123 flags for US geo-targeting and a sticky session
  3. Reads the 200 response, confirming the tunnel is established
  4. Wraps the tunneled connection with uTLS HelloChrome_Auto and 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_Auto covers 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_Auto but 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-abc123 for 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/http sends 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/tls produces 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/http TLS fingerprint differs from Chrome in cipher order, GREASE values, extensions, supported_groups, key_share, and ALPN — all detectable via JA3/JA4.
  • utls.HelloChrome_Auto replaces Go's handshake with a Chrome-accurate ClientHello. Use utls.HelloSafari_16_0 for Safari-specific fingerprinting.
  • CycleTLS and azuretls-client offer higher-level APIs if you don't want to manage DialTLSContext manually.
  • TLS fingerprint mimicry without residential proxies is ineffective — datacenter IPs contradict the browser fingerprint. Route through gate.proxyhat.com:8080 with geo-targeting flags like user-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/all before deploying and after every update.

Frequently asked questions

What is Fixing Go's net/http TLS Fingerprint?

Fixing Go's net/http TLS fingerprint means replacing the default crypto/tls ClientHello — which has a static, recognizable JA3/JA4 hash — with a browser-accurate fingerprint using uTLS. Go's default TLS handshake lacks GREASE values, sends ciphers in a fixed order, and omits Chrome-specific extensions, making it instantly detectable by WAFs like Cloudflare and Akamai.

Why does Fixing Go's net/http TLS Fingerprint matter for proxy users?

TLS fingerprint mimicry alone fails without a clean IP. If your Go client sends a Chrome fingerprint from a datacenter IP, WAFs flag the mismatch. Proxy users need residential proxies so the IP reputation matches the browser profile. Without this, even a perfect uTLS fingerprint gets blocked because the network identity contradicts the TLS identity.

Which proxy type works best for Fixing Go's net/http TLS Fingerprint?

Residential proxies work best because they provide ISP-assigned IPs that match a browser profile. Datacenter proxies have IP ranges that WAFs recognize as hosting providers, creating an immediate mismatch with a Chrome or Safari fingerprint. Route through gate.proxyhat.com:8080 with country and session flags like user-country-US-session-abc123 for optimal results.

How do you avoid blocks when implementing Fixing Go's net/http TLS Fingerprint?

Combine uTLS HelloChrome_Auto with residential proxies on gate.proxyhat.com, match your User-Agent to the TLS profile, ensure HTTP/2 negotiation via ALPN, preserve HTTP header order, and rotate sessions every 20-50 requests. Test against tls.peet.ws/api/all before deploying, and update uTLS every 2-3 months to track Chrome's evolving ClientHello.

Test your proxies against real anti-bot defenses

Free proxy checker — latency, anonymity and block signals in one click.

Run a free check
← Back to Blog