Si construyes scrapers o clientes HTTP en Go y de pronto recibes 403 Forbidden o desafíos CAPTCHA en sitios que antes respondían sin problemas, lo más probable es que no sea tu lógica de scraping: es tu huella TLS. Corregir la huella TLS de net/http en Go se ha convertido en un requisito básico para cualquier automatización seria contra WAFs modernos como Cloudflare, Akamai, Datadome o PerimeterX. La biblioteca estándar crypto/tls de Go genera un ClientHello predecible y muy poco parecido al de Chrome, lo que produce un JA3/JA4 distintivo que los sistemas anti-bot clasifican como tráfico automatizado antes siquiera de mirar tu User-Agent.
En esta guía profundizamos en por qué ocurre el problema, cómo capturar tu propia huella, qué señales concretas del ClientHello difieren entre Go y Chrome, y cómo solucionarlo con refraction-networking/utls, alternativas de más alto nivel como CycleTLS y azuretls-client, y por qué todo eso falla sin una identidad de red limpia vía proxies residenciales.
Por qué corregir la huella TLS de net/http en Go es imprescindible
El handshake TLS comienza con el mensaje ClientHello, donde el cliente lista sus cipher suites, extensiones, grupos soportados y algoritmos de firma. Ese mensaje se envía antes de cualquier capa HTTP y, crucialmente, antes de que el servidor vea tu User-Agent. Los WAF modernos inspeccionan el ClientHello y derivan un hash compacto —JA3 y, más recientemente, JA4— que actúa como identificador de la pila TLS del cliente.
El problema con Go es que crypto/tls emite un ClientHello estático: el orden de cipher suites es fijo, no incluye valores GREASE, y faltan extensiones que Chrome siempre envía. El resultado es un JA3 que cualquier WAF con reglas básicas marca como non-browser en milisegundos. No importa que pongas User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36: tu JA3 grita Go-http-client antes de que el servidor lea esa cabecera.
La regla de oro: el User-Agent describe quién eres; el JA3/JA4 demuestra qué pila TLS usas. Si ambos no coinciden, el WAF asume impersonación.
Cómo capturar tu propio JA3/JA4
Antes de cambiar nada, mide tu huella actual. El endpoint público tls.peet.ws/api/all devuelve tu JA3, JA4, JA4H y todas las señales del ClientHello en JSON. Ejecuta esto con Go puro:
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.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))
}
Verás algo como "ja3": "193,4865,4866,4867,..." y "ja4": "t13d1516h2_...". Compáralo con el de un Chrome real visitando el mismo endpoint desde tu navegador. Las diferencias son enormes: el JA3 de Go puro es corto y estable; el de Chrome incluye valores GREASE y un orden de extensiones mucho más largo. Esa divergencia es exactamente lo que los WAFs explotan.
Señales del ClientHello: Go vs Chrome en detalle
Para entender por qué go tls fingerprint differs from chrome, hay que mirar los campos concretos del ClientHello. Estos son los que más pesan en el cálculo de JA3/JA4:
1. Cipher suites (order matters)
Chrome envía cipher suites con valores GREASE intercalados (por ejemplo 0x0a0a, 0x1a1a, 0x2a2a) antes de las suites reales, y luego un conjunto ordenado que prioriza TLS 1.3 (1301, 1302, 1303) seguido de ECDHE con AES-GCM y ChaCha20-Poly1305. Go, en cambio, lista las suites en un orden fijo definido en crypto/tls/cipher_suites.go sin ningún GREASE. Ese orden estable y sin ruido es una señal inequívoca.
2. supported_groups
Chrome envía x25519 (0x001d), secp256r1 (0x0017), secp384r1 (0x0018) con un valor GREASE al inicio. Go envía los mismos grupos pero en orden distinto y sin GREASE. JA4 codifica los grupos en el segmento d del hash, así que cualquier reorden cambia el fingerprint.
3. signature_algorithms
Chrome lista 0x0403 (ecdsa-secp256r1-sha256), 0x0804 (rsa-pss-sha256), 0x0401 (rsa-pkcs1-sha256), etc., en un orden específico y con 0x0202 (rsa-pkcs1-sha1) en algunos perfiles. Go omite SHA1 por defecto y usa un orden distinto. JA3 incluye signature_algorithms en su hash; JA4 lo trata en un componente separado pero igual de discriminatorio.
4. ALPN
Chrome negocia h2,http/1.1. Go también lo hace si configuras ForceAttemptHTTP2 = true, pero si no lo haces, tu ClientHello omite ALPN y el JA3 cambia. Muchos scrapers en Go se olvidan de esto y emiten un ClientHello sin h2, algo que ningún Chrome real haría en 2024-2026.
5. key_share
En TLS 1.3, el cliente envía key_share con x25519 y secp256r1. Chrome incluye ambos. Go históricamente enviaba solo x25519 o secp256r1 según configuración, lo que reduce el tamaño del ClientHello y lo hace distinguible.
6. GREASE
GREASE (RFC 8701) fue diseñado por Google precisamente para evitar que las implementaciones se acostumbraran a valores fijos. Chrome inserta valores GREASE reservados (0x0a0a, 0x1a1a, etc.) en cipher suites, extensiones, grupos y versiones. Go no implementa GREASE en crypto/tls. Su ausencia es una de las señales más fuertes de que el cliente no es un navegador.
| Señal ClientHello | Go net/http (crypto/tls) | Chrome real |
|---|---|---|
| GREASE en cipher suites | Ausente | Presente (0x0a0a, 0x1a1a...) |
| Orden de cipher suites | Fijo, definido en stdlib | Chrome-specific, TLS 1.3 primero |
| supported_groups | x25519, secp256r1, secp384r1 (sin GREASE) | GREASE + x25519 + secp256r1 + secp384r1 |
| signature_algorithms | Sin SHA1, orden propio | Con SHA1 en algunos perfiles, orden Chrome |
| key_share | 1 grupo normalmente | x25519 + secp256r1 |
| ALPN | Opcional (ForceAttemptHTTP2) | Siempre h2, http/1.1 |
| Extensiones extra | Mínimas | ~15-20 (padding, signed_cert_timestamp, etc.) |
Solución: uTLS y HelloChrome_Auto en Go
La biblioteca refraction-networking/utls es un fork de crypto/tls que permite especificar un ClientHelloID que replica el ClientHello de navegadores reales. El perfil utls.HelloChrome_Auto sigue la versión más reciente de Chrome disponible en la biblioteca, mientras que utls.HelloSafari_16_0 fija la huella a Safari 16.0 cuando necesitas ese perfil concreto.
El patrón estándar para utls chrome fingerprint go es reemplazar el handshake TLS dentro de un http.Transport usando DialTLSContext:
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"time"
utls "github.com/refraction-networking/utls"
)
func newUTLSTransport() *http.Transport {
return &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
tcpConn, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
config := &utls.Config{ServerName: host}
uConn := utls.UClient(tcpConn, config, utls.HelloChrome_Auto)
if err := uConn.HandshakeContext(ctx); err != nil {
tcpConn.Close()
return nil, err
}
return uConn, nil
},
ForceAttemptHTTP2: true,
}
}
func main() {
client := &http.Client{
Transport: newUTLSTransport(),
Timeout: 30 * 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))
}
Tras ejecutar esto, tu JA3/JA4 en tls.peet.ws debería coincidir con el de Chrome. Si necesitas el perfil Safari en lugar de Chrome, sustituye utls.HelloChrome_Auto por utls.HelloSafari_16_0. El resto del código no cambia.
Consideraciones al usar HelloChrome_Auto
- Auto vs versiones fijas:
HelloChrome_Autose actualiza con cada release de uTLS, pero si necesitas reproducibilidad exacta entre deploys, fija a una versión concreta comoHelloChrome_120_PSKo la que corresponda a tu navegador objetivo. - HTTP/2: uTLS negocia ALPN
h2correctamente, pero elhttp.Transportde Go necesitaForceAttemptHTTP2 = truepara quenet/httpuse HTTP/2 sobre la conexión uTLS. - Order de extensiones: uTLS respeta el orden de extensiones de Chrome, lo que afecta directamente a JA4 (que es sensible al orden, a diferencia de JA3 que ordena alfabéticamente).
Alternativas de más alto nivel: CycleTLS y azuretls-client
Si no quieres gestionar DialTLSContext manualmente, existen wrappers que integran uTLS con un cliente HTTP más ergonómico.
CycleTLS
CycleTLS expone un cliente que combina uTLS con soporte HTTP/2 y una API sencilla. Es útil cuando necesitas rotar fingerprints rápidamente entre Chrome, Firefox y Safari sin tocar el transporte. Su principal limitación es que el soporte de HTTP/2 es propio (no usa el golang.org/x/net/http2 estándar), lo que puede causar incompatibilidades sutiles con servidores que validan SETTINGS frames.
azuretls-client
azuretls-client es una alternativa más reciente que integra uTLS, soporte HTTP/2 nativo vía x/net/http2, y gestión de cookies/sesiones. Es una buena opción cuando necesitas un cliente completo con fingerprint consistente y quieres evitar escribir el DialTLSContext a mano.
| Librería | Nivel de abstracción | HTTP/2 | Rotación de fingerprint | Ideal para |
|---|---|---|---|---|
| refraction-networking/utls | Bajo (transporte TLS) | Vía http.Transport | Manual (ClientHelloID) | Control total, integración custom |
| CycleTLS | Al (cliente HTTP) | Propio | Fácil por request | Prototipos rápidos, rotación frecuente |
| azuretls-client | Al (cliente HTTP) | x/net/http2 | Fácil por request | Scrapers en producción, sesiones largas |
Por qué el fingerprint solo no basta: identidad de red limpia
Aquí es donde la mayoría de guías se detienen y por eso la mayoría de scrapers siguen siendo bloqueados. Tener un JA3/JA4 perfecto de Chrome no sirve de nada si tu IP es un datacenter de DigitalOcean con historial de abuso, o si todas tus peticiones salen del mismo IP residencial durante 8 horas seguidas. Los WAF modernos combinan múltiples señales:
- JA3/JA4 TLS: ¿la pila TLS coincide con el User-Agent?
- JA4H HTTP: orden de cabeceras, Accept-Language, secuencia de frames HTTP/2.
- Reputación de IP: ASN datacenter vs ISP residencial, histórico de abuso, geolocalización vs Accept-Language.
- Señales de comportamiento: ratio de peticiones, navegación sin referer, tiempo de permanencia cero.
- Canvas/WebGL fingerprint (si hay JS): hash del canvas renderizado, vendor WebGL, ángulos de muestreo.
Si tu JA3 dice Chrome pero tu IP es 143.244.x.x (DigitalOcean), el WAF ve una contradicción: ningún Chrome real navega desde un ASN cloud. La solución es rutear tu cliente uTLS a través de proxies residenciales para que tanto la huella TLS como la reputación de IP sean coherentes.
Go + uTLS + ProxyHat residencial: ejemplo completo
ProxyHat ofrece proxies residenciales con geo-targeting y sesiones pegajosas. El gateway HTTP es gate.proxyhat.com:8080 y las flags de país/sesión van en el username. Este ejemplo muestra cómo dial a través del proxy con country-US-session-abc123 y luego hacer el handshake uTLS sobre esa conexión tunelizada:
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
utls "github.com/refraction-networking/utls"
)
const (
proxyUser = "user-country-US-session-abc123"
proxyPass = "pass"
proxyHost = "gate.proxyhat.com"
proxyPort = "8080"
)
func dialThroughProxy(ctx context.Context, targetAddr string) (net.Conn, error) {
pxy := &url.URL{
Scheme: "http",
User: url.UserPassword(proxyUser, proxyPass),
Host: net.JoinHostPort(proxyHost, proxyPort),
}
// Conectar al proxy
proxyConn, err := (&net.Dialer{Timeout: 15 * time.Second}).
DialContext(ctx, "tcp", pxy.Host)
if err != nil {
return nil, fmt.Errorf("dial proxy: %w", err)
}
// Enviar CONNECT hacia el destino
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: targetAddr},
Host: targetAddr,
Header: make(http.Header),
}
connectReq.Write(proxyConn)
br := bufio.NewReader(proxyConn)
resp, err := http.ReadResponse(br, connectReq)
if err != nil {
proxyConn.Close()
return nil, fmt.Errorf("read CONNECT resp: %w", err)
}
if resp.StatusCode != 200 {
proxyConn.Close()
return nil, fmt.Errorf("CONNECT status %d", resp.StatusCode)
}
return proxyConn, nil
}
func newProxiedUTLSTransport() *http.Transport {
return &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
conn, err := dialThroughProxy(ctx, addr)
if err != nil {
return nil, err
}
config := &utls.Config{ServerName: host}
uConn := utls.UClient(conn, config, utls.HelloChrome_Auto)
if err := uConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return uConn, nil
},
ForceAttemptHTTP2: true,
}
}
func main() {
client := &http.Client{
Transport: newProxiedUTLSTransport(),
Timeout: 45 * 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))
}
Con esta configuración, el servidor destino ve: (1) un ClientHello idéntico al de Chrome 126+, (2) una IP residencial de EE.UU. con reputación limpia, y (3) una sesión pegajosa abc123 que mantiene el mismo IP entre peticiones para evitar saltos sospechosos. Si necesitas SOCKS5 en lugar de HTTP CONNECT, usa gate.proxyhat.com:1080 con el esquema socks5://.
Para casos de uso específicos, consulta nuestras guías de web scraping y SERP tracking, o revisa las ubicaciones disponibles y los planes de precios de ProxyHat. La documentación oficial detalla todas las flags de geo-targeting y rotación.
JA4 reemplaza a JA3: cómo mantener tus parrots actualizados
JA4 es el sucesor de JA3, introducido por FoxIO en 2023. A diferencia de JA3, que ordena extensiones alfabéticamente y es insensible al orden, JA4 preserva el orden de cipher suites y extensiones, lo que lo hace más preciso para distinguir implementaciones. JA4 también separa los componentes en un formato más estructurado: t13d1516h2_..., donde cada segmento codifica versión TLS, número de cipher suites, extensiones, ALPN, etc.
Esto tiene implicaciones directas para go ja3 ja4 spoof:
- JA3 era forgiving: si tenías las mismas extensiones en distinto orden, el hash coincidía. Por eso muchos spoofers de JA3 funcionaban aunque el orden no fuera exacto.
- JA4 es estricto: el orden importa. Si uTLS envía las extensiones en orden distinto al de Chrome real, JA4 no coincidirá aunque JA3 sí.
- JA4H cubre HTTP: el orden de cabeceras HTTP y los SETTINGS frames de HTTP/2 también se hashean. Necesitas que tu cliente envíe cabeceras en el orden correcto.
Para mantener tus fingerprints actualizados:
- Mide regularmente: ejecuta tu cliente contra
tls.peet.ws/api/allcada vez que actualices uTLS o cambies de perfil. - Compara con Chrome real: visita el mismo endpoint desde Chrome y guarda el JA4 de referencia.
- Actualiza uTLS:
go get -u github.com/refraction-networking/utlsperiódicamente.HelloChrome_Autosigue la versión más reciente soportada. - Pinea la versión en producción: si necesitas reproducibilidad, usa un
HelloChrome_XXXespecífico en lugar de_Auto. - Verifica JA4H: asegúrate de que el orden de cabeceras HTTP de tu cliente coincida con Chrome (Host, Connection, 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).
Errores comunes y casos límite
1. Olvidar ForceAttemptHTTP2
Sin ForceAttemptHTTP2 = true, net/http no negocia h2 vía ALPN incluso si uTLS lo ofrece. El resultado: tu JA3 dice Chrome pero tu servidor recibe HTTP/1.1, lo cual es otra contradicción detectable.
2. Reutilizar conexiones sin fingerprint consistente
Si rotas entre HelloChrome_Auto y HelloFirefox_Auto en el mismo http.Client, el connection pool de http.Transport puede reutilizar una conexión TLS negociada con un perfil distinto. Usa clientes separados por perfil o deshabilita el pool con DisableKeepAlives: true cuando rotes.
3. No rotar sesiones de proxy
Una sesión pegajosa session-abc123 es útil para mantener estado, pero si haces 5000 peticiones en 10 minutos desde el mismo IP residencial, el WAF te bloqueará por comportamiento aunque tu JA4 sea perfecto. Rota sesiones cada 50-100 peticiones o usa rotación per-request para volúmenes altos.
4. Ignorar el orden de cabeceras HTTP
JA4H hashea el orden de cabeceras. Go's http.Header es un map, lo que significa que el orden de escritura no se preserva en todos los casos. Usa Header.Set en orden explícito o librerías que respeten el orden como azuretls-client.
5. No respetar robots.txt y ToS
El acceso legítimo —investigación de seguridad, pentesting autorizado, automatización de tus propios servicios— debe respetar robots.txt y los términos de servicio del objetivo. Evitar detección TLS no es licencia para ignorar límites éticos y legales. En la UE, el GDPR aplica al procesamiento de datos personales; en California, el CCPA tiene requisitos similares.
Conclusiones clave
- Go's
crypto/tlsemite un ClientHello estático sin GREASE que produce un JA3/JA4 distinguible de cualquier navegador real.- uTLS con
HelloChrome_Auto(oHelloSafari_16_0) reemplaza el handshake y replica el ClientHello de Chrome/Safari.- El fingerprint TLS solo no basta: sin una IP residencial limpia, el WAF ve una contradicción entre JA4 y reputación de IP.
- ProxyHat en
gate.proxyhat.com:8080con flagscountry-US-session-abc123da coherencia entre JA4 e identidad de red.- JA4 reemplaza a JA3 y es sensible al orden: mide regularmente contra
tls.peet.ws/api/ally actualiza uTLS.- CycleTLS y azuretls-client son alternativas válidas cuando no quieres gestionar
DialTLSContextmanualmente.
Corregir la huella TLS de net/http en Go es un paso necesario pero no suficiente. La combinación de uTLS para el fingerprint de transporte y proxies residenciales para la identidad de red es lo que hace que tu automatización o investigación de seguridad se vea como tráfico humano real. Implementa ambos, mide contra tls.peet.ws, y mantén tus perfiles actualizados a medida que Chrome evoluciona.






