Skip to main content

Custom JA3

WithCustomFingerprint takes a raw JA3 string. The preset still drives HTTP/2 SETTINGS, headers, and the priority table, but the TLS ClientHello gets rebuilt from your JA3 on every connection.

When to use this

Reach for WithCustomFingerprint (JA3 only) when:

  • You captured a JA3 from a real browser session and want to mirror that ClientHello exactly.
  • You're testing what JA3 hashes look like for a given cipher / extension / curve permutation.
  • You only care about TLS, the preset's headers and H2 are fine.

Reach for the JSON Preset Builder instead when:

  • You need to tweak HTTP/2 SETTINGS along with the JA3.
  • You want to change the User-Agent, sec-ch-ua list, or any other HTTP header.
  • You want the change saved as a named preset for reuse.

JA3 is a one-liner. The JSON builder's a few more, but worth it the second you need anything beyond TLS.

JA3 string format

TLSVersion,CipherSuites,Extensions,EllipticCurves,PointFormats

Five comma-separated lists. Inside each list, values are dash-separated decimal IDs. Chrome 148 example:

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-21,29-23-24,0
  • 771: TLS 1.2 protocol field (everything modern advertises TLS 1.2, then upgrades to 1.3 inside extensions).
  • 4865-4866-...: cipher suite IDs (TLS_AES_128_GCM_SHA256 is 4865, etc).
  • 0-23-65281-...: TLS extension IDs (0=server_name, 23=session_ticket, 65281=renegotiation_info, etc). Order matters for the JA3 hash, but real Chrome shuffles this list per connection.
  • 29-23-24: supported groups / curves (29=x25519, 23=secp256r1, 24=secp384r1).
  • 0: EC point formats (0=uncompressed).
caution

DevTools straight-up won't show you what TLS extensions Chrome put on the wire, and the order it shows is a lie since Chrome shuffles per connection. Capture from tls.peet.ws or a passive observer if you want the truth.

API

CustomFingerprint carries the JA3 plus a few uTLS-level extras. Setting JA3 flips the session into TLS-only mode automatically. Preset HTTP headers stop being applied, so you supply your own per request.

package main

import (
"context"
"fmt"
"io"

"github.com/sardanioss/httpcloak"
)

func main() {
s := httpcloak.NewSession("chrome-148-windows",
httpcloak.WithCustomFingerprint(httpcloak.CustomFingerprint{
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-21,29-23-24,0",
// optional uTLS extras:
ALPN: []string{"h2", "http/1.1"},
SignatureAlgorithms: []string{"ecdsa_secp256r1_sha256", "rsa_pss_rsae_sha256", "rsa_pkcs1_sha256"},
CertCompression: []string{"brotli"},
}),
)
defer s.Close()

resp, _ := s.Get(context.Background(), "https://tls.peet.ws/api/all")
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println(string(body))
}

Verification

Send the request, read the response, look at tls.ja3 and tls.ja3_hash. They mirror your input exactly:

INPUT 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-21,29-23-24,0
INPUT akamai: 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p

OUTPUT 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-21,29-23-24,0
OUTPUT ja3_hash: cd08e31494f9531f560d64c695473da9
OUTPUT ja4: t13d1516h2_8daaf6152771_f37e75b10bcc
OUTPUT akamai: 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
OUTPUT akamai_hash: 52d84b11737d980aef856699f885ca86

The reflected ja3 is byte-identical to the input. ja3_hash is a stable MD5 of that string, so it stays stable across runs (unlike preset Chrome where extensions shuffle). ja4 differs from the underlying chrome-148-windows (d8a2da3f94cd vs f37e75b10bcc) because we used a different extension list. That's expected and proves the override took effect.

TLS-only mode is automatic

Setting JA3 flips the session into TLS-only mode. The preset's HTTP headers go away, the preset's User-Agent goes away, and you set everything per request:

req := &httpcloak.Request{
Method: "GET",
URL: "https://tls.peet.ws/api/all",
Headers: map[string][]string{
"User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"},
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
"Accept-Language": {"en-US,en;q=0.9"},
},
}
resp, _ := s.Do(context.Background(), req)

Want JA3 override and preset headers? Use the JSON Preset Builder approach instead. Describe the preset, override tls.ja3 in the JSON, leave headers alone, register, send.

Limitations

  • JA3 is deprecated for a reason. Modern anti-bot stacks key on JA4 / peetprint / akamai. Mirroring a Chrome JA3 while inheriting Chrome's H2 SETTINGS gets you the right JA4 / peetprint / akamai too, that's the case shown above. But if your JA3 says one browser and your H2 says another, you're inconsistent and easy to flag.
  • Setting JA3 clears the preset's client_hello ID. The session rebuilds a ClientHello from the JA3 every connection. uTLS handles it, but it's lossy compared to a real browser ClientHelloID. JA3 doesn't capture extension data like ALPS, key share groups, application-settings, so the resulting handshake is close-but-not-identical to real Chrome. For byte-exact Chrome bytes, use a preset.
  • The extras (ALPN, SignatureAlgorithms, CertCompression, PermuteExtensions) are uTLS-specific knobs that ride alongside the JA3. They don't show up inside the JA3 string itself, but they do hit the wire.