# httpcloak — Full Documentation This file is the entire httpcloak documentation site, concatenated for easy AI-agent consumption. Sections are listed in the same order as the sidebar at https://httpcloak.dev/. Source: https://github.com/sardanioss/httpcloak/tree/main/docs/docs --- # httpcloak httpcloak is a Go HTTP client that puts the same wire bytes on the line as a real browser, across HTTP/1.1, HTTP/2, and HTTP/3. The Go core handles TLS (uTLS), HTTP/2, HTTP/3 (QUIC), proxying (HTTP CONNECT, SOCKS5, MASQUE), and per-resource RFC 7540 / RFC 9218 stream priorities. Python, Node.js, and .NET get the same API through a shared cgo library. ## Quickstart ```go package main import ( "context" "fmt" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-latest") defer s.Close() resp, _ := s.Get(context.Background(), "https://example.com/") fmt.Println(resp.StatusCode) } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-latest") as s: resp = s.get("https://example.com/") print(resp.status_code) ``` ```js const { Session } = require('httpcloak'); const s = new Session({ preset: 'chrome-latest' }); const resp = await s.get('https://example.com/'); console.log(resp.statusCode); s.close(); ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-latest"); var resp = await s.GetAsync("https://example.com/"); Console.WriteLine(resp.StatusCode); ``` ## Features ### Connection lifecycle - **`Refresh()`**: drops every live connection but keeps TLS session tickets, the way a browser tab does on reload. Next request resumes 0-RTT on the same preset. - **`RefreshWithProtocol()` / `WithSwitchProtocol`**: hop between H1, H2, and H3 mid-session and re-handshake on the new transport. - **`Save()` / `LoadSession()`**: persists the session (tickets, cookies, preset state) to disk so you can resume across processes. - **`Warmup(ctx, url)`**: multi-hop browser-style warmup before the real request. Pre-populates cookies, ECH state, and session tickets. ### Fingerprint customization - **JSON preset describe / load**: `describe_preset(name)` dumps the full preset spec as JSON. `load_preset_from_json(json)` registers a mutated copy at runtime. Round-trips byte-for-byte. - **Per-resource priority table**: RFC 7540 stream weights and RFC 9218 `priority:` headers picked per request from `Sec-Fetch-Dest`. Every RFC 7540 preset inherits a 14-dest default table, overridable per preset. - **Custom JA3 + Akamai shorthand**: `WithCustomFingerprint` takes a JA3 string and an Akamai HTTP/2 fingerprint string. Fine-grained override without writing a whole preset. - **Cookie jar opt-out**: `WithoutCookieJar()` kills the internal jar. You handle cookies via per-request headers. ### Privacy and advanced TLS - **ECH (Encrypted Client Hello)**: on by default. Encrypts SNI on the wire. `WithDisableECH()` skips the DNS lookup. `WithECHFrom(domain)` borrows an ECH config from another domain (e.g. `cloudflare-ech.com`). - **MASQUE**: HTTP/3 CONNECT-UDP proxy support. Tunnels QUIC over a remote endpoint. - **Speculative TLS for proxy CONNECT**: `WithEnableSpeculativeTLS()` pipelines the CONNECT request with the inner ClientHello. Saves one RTT on every proxied connection. - **TLS keylog**: `WithKeyLogFile(path)` writes a Wireshark-compatible SSLKEYLOGFILE so you can decrypt offline. ### Network and proxy - **Proxy types**: HTTP CONNECT, SOCKS5, SOCKS5 with UDP ASSOCIATE, and MASQUE. Split-config works via `WithSessionTCPProxy` + `WithSessionUDPProxy` (e.g. HTTP proxy for H1/H2, MASQUE for H3). - **Source-address binding**: `WithLocalAddress(string)` and `WithLocalAddrIP(net.IP)` pin every dial socket to a chosen local IP. `IP_FREEBIND` / `IPV6_FREEBIND` gets set on Linux so addresses you haven't configured on the interface (routed IPv6 prefix rotation, say) work without `CAP_NET_ADMIN`. - **`WithSessionPreferIPv4()`**: skips Happy Eyeballs and forces v4. ### Presets - **Chrome**: 133, 141, 143, 144, 145, 146, 147, 148, with per-OS variants (Windows / Linux / macOS / Android / iOS) where they actually differ. - **Firefox**: 133, 148. - **Safari**: 18 (desktop), 17 / 18 (iOS). - **`chrome-latest` aliases**: `chrome-latest`, `chrome-latest-windows`, `chrome-latest-linux`, `chrome-latest-macos`, `chrome-latest-android`, `chrome-latest-ios`. Auto-track the newest shipped Chrome major. ### Bindings - **Go**: `go get github.com/sardanioss/httpcloak` - **Python**: `pip install httpcloak` - **Node.js**: `npm install httpcloak` - **.NET**: `dotnet add package HttpCloak` ## Where to next - New here? Start with [Getting Started](/getting-started). - Looking up something specific? Hit the [Reference](/reference). - Need a proxy? See [Proxies](/proxies). - Want to dial in the fingerprint? See [Fingerprinting](/fingerprinting). - Long-running session, Refresh, Warmup, Save/Restore? See [Connection Lifecycle](/connection-lifecycle). - ECH, keylog, speculative TLS? See [Advanced TLS](/advanced-tls). - End-to-end patterns for real builds? See [Recipes](/recipes). --- # Installation Pick your binding. Once it is installed, head to [First Request](./getting-started/first-request) to send something. ```sh go get github.com/sardanioss/httpcloak ``` Requires Go 1.22+. The Go core has no cgo dependency. ```sh pip install httpcloak ``` Wheels ship for `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`. Python 3.9+. ```sh npm install httpcloak ``` Node 18+. Optional native deps auto-resolve to your platform; ESM and CJS both supported. ```sh dotnet add package HttpCloak ``` .NET 8+ on the same five platforms as Python. Uses P/Invoke to call into the shared library. Next: [send your first request](./getting-started/first-request). --- # Getting Started Fire off your first request, see how presets work, and get a feel for the day-to-day knobs you'll actually touch. ## In this section - [First Request](./first-request): your first call with the default Chrome preset - [Presets Explained](./presets-explained): what a preset bundles, how to pick one - [Common Options](./common-options): timeouts, redirects, retries, the boring but essential stuff --- # First Request httpcloak puts the same bytes on the wire as a real browser. Same TLS ClientHello, same HTTP/2 SETTINGS frame, same header order, same priority frames. If a site fingerprints your client, you show up looking like Chrome (or Firefox, or Safari) instead of Go's `net/http` or Python `requests`. This page is the four-line "does it work" check. Pick your language, copy the snippet, run it. You should get a 200 back from `tls.peet.ws/api/all` with a Chrome-shaped fingerprint in the response. ## The snippet ```go package main import ( "context" "fmt" "time" "github.com/sardanioss/httpcloak" ) func main() { sess := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTimeout(30*time.Second), ) defer sess.Close() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := sess.Get(ctx, "https://tls.peet.ws/api/all") if err != nil { panic(err) } defer resp.Close() fmt.Println("status:", resp.StatusCode) fmt.Println("protocol:", resp.Protocol) body, _ := resp.Text() fmt.Println(body) } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-latest", timeout=30) as session: r = session.get("https://tls.peet.ws/api/all") print("status:", r.status_code) print("protocol:", r.http_version) print(r.text) ``` ```javascript const { Session } = require("httpcloak"); (async () => { const session = new Session({ preset: "chrome-latest", timeout: 30 }); try { const r = await session.get("https://tls.peet.ws/api/all"); console.log("status:", r.statusCode); console.log("protocol:", r.httpVersion); console.log(r.text); } finally { session.close(); } })(); ``` ```csharp using HttpCloak; using var session = new Session(preset: "chrome-latest", timeout: 30); var r = session.Get("https://tls.peet.ws/api/all"); Console.WriteLine($"status: {r.StatusCode}"); Console.WriteLine($"protocol: {r.HttpVersion}"); Console.WriteLine(r.Text); ``` ## What you should see The full response is a chunky JSON blob with TLS, HTTP/2, and header data. Here's the trimmed version with the parts that actually matter: ```json { "http_version": "h2", "tls": { "ja3_hash": "55ecc08008f90a8b2a5c5289ab0f8b69", "ja4": "t13d1516h2_8daaf6152771_d8a2da3f94cd" }, "http2": { "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 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" } ``` A few things worth flagging: - `http_version` is `h2`. Chrome speaks HTTP/2 by default to anything ALPN-capable, so you do too. HTTP/3 kicks in if the server advertises it via Alt-Svc. Pin one with `WithForceHTTP2()` or `WithForceHTTP3()` if you'd rather not let it negotiate. - `ja4` is stable across runs on the same preset. `ja3_hash` isn't, because Chrome shuffles GREASE extension values on every ClientHello and that bleeds into the JA3 string. JA4 strips GREASE. Match against JA4, ignore JA3. - `akamai_fingerprint_hash` rolls up H2 SETTINGS, WINDOW_UPDATE, PRIORITY, and pseudo-header order into one value. It should line up with what real Chrome 148 ships. :::tip tls.peet.ws is your friend Bookmark `tls.peet.ws/api/all`. Anytime you tweak a preset, drop in a custom JA3, or wonder why a target's still flagging you, hit this endpoint and diff the response against a real browser. DevTools won't even show you the request header order, so this is the easiest source of truth. ::: ## Where to next - [Presets Explained](./presets-explained) for what `chrome-latest` actually bundles and how to pick something else. - [Common Options](./common-options) for timeouts, retries, redirects, and the boring stuff every client has. - [Fingerprinting overview](/fingerprinting) when you want to start hand-tuning the wire bytes. --- # Presets Explained A preset is the whole bundle of wire-level fingerprint data for one specific browser build. When you write `NewSession("chrome-148-windows")`, here's what loads: - The TLS ClientHello: cipher list, extension order, supported groups, ALPN, signature algorithms, key shares, GREASE positions, ECH config, the whole lot. - HTTP/2 SETTINGS frame values, the WINDOW_UPDATE delta, PRIORITY frame defaults, and the pseudo-header order on every request. - Default request headers in the exact order Chrome sends them. `sec-ch-ua`, `accept`, `sec-fetch-*`, all of it. - Per-resource priority (RFC 7540 stream weights for H2, RFC 9218 `priority` headers for H2/H3) keyed off `sec-fetch-dest`. - For HTTP/3: the QUIC initial parameters and the same H3 SETTINGS frame Chrome ships with. You don't pick TLS and headers separately. You pick a browser and a build, and the whole bundle moves together. That's the only way to stay self-consistent. A Chrome 148 ClientHello with Firefox header order is going to look busted to anyone fingerprinting hard. ## How to pick one Start with `chrome-latest`. It tracks the newest Chrome that's shipped. If you need a specific OS variant (some sites gate on `sec-ch-ua-platform`), grab `chrome-latest-windows`, `chrome-latest-linux`, `chrome-latest-macos`, `chrome-latest-android`, or `chrome-latest-ios`. Need to pin a specific build? Say a target's blocking Chrome 148 specifically and you'd rather look like Chrome 144. Use the explicit version string: `chrome-144-windows`. ```go // Default desktop Chrome, OS picked by the latest alias map sess := httpcloak.NewSession("chrome-latest") // Mobile UA + matching TLS for a phone-shaped fingerprint mobile := httpcloak.NewSession("chrome-148-android") // Pinned to an older build old := httpcloak.NewSession("chrome-144-windows") ``` ```python session = httpcloak.Session(preset="chrome-latest") mobile = httpcloak.Session(preset="chrome-148-android") old = httpcloak.Session(preset="chrome-144-windows") ``` ```javascript const session = new Session({ preset: "chrome-latest" }); const mobile = new Session({ preset: "chrome-148-android" }); const old = new Session({ preset: "chrome-144-windows" }); ``` ```csharp using var session = new Session(preset: "chrome-latest"); using var mobile = new Session(preset: "chrome-148-android"); using var old = new Session(preset: "chrome-144-windows"); ``` ## What's available Every preset family ships per-OS variants wherever the underlying browser actually differs by OS. Chrome differs across Windows, Linux, macOS, Android, and iOS (iOS Chrome is WebKit under the hood, totally different stack). Firefox barely differs across desktop OSes, so there's one desktop build. **Chrome desktop**: 133, 141, 143, 144, 145, 146, 147, 148. Each has `-windows`, `-linux`, `-macos` suffixes (so `chrome-148-windows` and friends). Bare `chrome-148` aliases to whatever OS the library defaults to. **Chrome mobile**: `chrome-143-android` through `chrome-148-android`, and `chrome-143-ios` through `chrome-148-ios`. Mobile Chrome has its own UA, its own sec-ch-ua values, and on iOS a completely different TLS stack (WebKit again). **Firefox**: 133 and 148 desktop. No per-OS split. **Safari**: `safari-18` desktop, `safari-17-ios`, `safari-18-ios`. **Latest aliases**: `chrome-latest`, `chrome-latest-windows`, `chrome-latest-linux`, `chrome-latest-macos`, `chrome-latest-android`, `chrome-latest-ios`, `firefox-latest`, `safari-latest`, `safari-latest-ios`. These re-point to the newest shipped major on every release. Use them when you don't care about pinning to an exact build. For the full list with protocol support per preset, see [Reference: Presets](/reference/presets). ## Inheritance: the `based_on` chain Presets aren't standalone JSON blobs. They form a chain. Crack open `fingerprint/embedded/chrome-148-windows.json` and you'll see something like this: ```json { "version": 1, "preset": { "name": "chrome-148-windows", "based_on": "chrome-147-windows", "headers": { "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", "values": { "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"" } } } } ``` That's the whole file. Chrome 148 windows is just "Chrome 147 windows but bump the UA and sec-ch-ua to 148". TLS settings, H2 settings, header order, priority table, all inherited verbatim. It's deliberate. Real Chrome rarely changes its TLS or H2 wire format between minor versions. Most of what's "new in Chrome 148" is JS engine and rendering, not network. So a delta only ships when the wire actually changes (cipher reordering, new extension, SETTINGS bump, that kind of thing). Everything else is a UA bump on top of the `based_on` chain. The chain bottoms out at a "root" preset that carries the full TLS/H2 spec. Loading `chrome-148-windows` walks the chain at startup and merges layer by layer. Want to see the resolved bundle (post-merge)? There's a describe API. From Go: `fingerprint.Describe("chrome-148-windows")`. From Python: `httpcloak.describe_preset("chrome-148-windows")`. ## Building your own Drop a JSON file in your app, point httpcloak at it, and you've got a custom preset. Handy when you've captured a real browser session and want to ship that exact fingerprint without hand-editing Go code. Shape is the same as the embedded files. `based_on` is optional: point it at any registered preset to inherit, or omit it and supply the full TLS+H2 spec yourself. See [JSON Preset Builder](/fingerprinting/json-preset-builder) for the full schema and a worked example. ## Where to next - [Reference: Presets](/reference/presets) for the full table of what each preset supports (H1/H2/H3, OS, mobile/desktop). - [What is TLS Fingerprinting](/fingerprinting/what-is-tls-fingerprinting) for context on why these wire bytes matter. - [Custom JA3](/fingerprinting/custom-ja3) when you want to override individual TLS bits without rebuilding a whole preset. --- # Common Options Every HTTP client has the same handful of knobs. Timeouts, redirects, retries, default headers, cookies. This page covers those for httpcloak. Nothing fancy, just the everyday stuff you'll reach for in week one. For the full option surface, including everything that's not on this page, see [Reference: Options](/reference/options). ## Timeout `WithSessionTimeout` is the default timeout for every request on the session. You can override it per-request via the `Timeout` field on `Request` (or the `timeout` kwarg on the bindings). The session timeout covers the whole request: DNS, connect, TLS handshake, request send, response read. It doesn't cover reading the body once `Get`/`Do` has returned. You handle that yourself. ```go sess := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTimeout(10*time.Second), ) ``` ```python session = httpcloak.Session(preset="chrome-latest", timeout=10) ``` ```javascript const session = new Session({ preset: "chrome-latest", timeout: 10 }); ``` ```csharp using var session = new Session(preset: "chrome-latest", timeout: 10); ``` In Go, the timeout is also bounded by the `context.Context` you pass to `Get`/`Do`. Whichever fires first wins. Use the context for caller cancellation, the session timeout as a backstop. ## Redirects httpcloak follows redirects by default, up to 10 hops. You can kill that entirely, or just change the cap. ```go // Don't follow at all noRedir := httpcloak.NewSession("chrome-latest", httpcloak.WithoutRedirects()) // Follow but cap at 5 capped := httpcloak.NewSession("chrome-latest", httpcloak.WithRedirects(true, 5)) ``` ```python no_redir = httpcloak.Session(preset="chrome-latest", allow_redirects=False) capped = httpcloak.Session(preset="chrome-latest", max_redirects=5) ``` ```javascript const noRedir = new Session({ preset: "chrome-latest", allowRedirects: false }); const capped = new Session({ preset: "chrome-latest", maxRedirects: 5 }); ``` ```csharp using var noRedir = new Session(preset: "chrome-latest", allowRedirects: false); using var capped = new Session(preset: "chrome-latest", maxRedirects: 5); ``` When redirects are followed, the response object hands you the full chain via `response.History` (Go), `r.history` (Python), `r.history` (Node), `Response.History` (.NET). Each entry has the status, URL, and headers of the intermediate hop. The final URL lands on `FinalURL` / `final_url` / `finalUrl` / `FinalUrl`. When redirects are off, a 301/302 (or whatever) just comes back as the response, body and all. No magic. ## Retries Retries are off by default. Flip them on with `WithRetry(n)` for sane defaults, or reach for `WithRetryConfig` to tune the backoff window and the trigger status codes. Default retry-on-status when you enable retries is `[429, 500, 502, 503, 504]`. Heads up: retries on POST/PUT/PATCH bodies need the body to be re-readable. Pass a `bytes.Buffer` or a `[]byte`-backed reader, not a one-shot stream, otherwise the retry has nothing to send. ```go // 3 retries with default 500ms-10s exponential backoff on default statuses sess := httpcloak.NewSession("chrome-latest", httpcloak.WithRetry(3)) // Custom: 5 retries, 1s-30s backoff, only 429 and 503 tuned := httpcloak.NewSession("chrome-latest", httpcloak.WithRetryConfig(5, 1*time.Second, 30*time.Second, []int{429, 503}), ) ``` ```python session = httpcloak.Session(preset="chrome-latest", retry=3) tuned = httpcloak.Session( preset="chrome-latest", retry=5, retry_wait_min=1000, # ms retry_wait_max=30000, # ms retry_on_status=[429, 503], ) ``` ```javascript const session = new Session({ preset: "chrome-latest", retry: 3 }); const tuned = new Session({ preset: "chrome-latest", retry: 5, retryWaitMin: 1000, retryWaitMax: 30000, retryOnStatus: [429, 503], }); ``` ```csharp using var session = new Session(preset: "chrome-latest", retry: 3); using var tuned = new Session( preset: "chrome-latest", retry: 5, retryWaitMin: 1000, retryWaitMax: 30000, retryOnStatus: new[] { 429, 503 }); ``` ## Custom headers Presets ship with a default header set in the right order. You don't want to touch this most of the time, the whole point is to look like Chrome. But you'll usually need to tack on an `Authorization`, `Cookie`, `Referer`, or some app-specific header. Just add them per-request. They get merged into the preset's order at the correct slot (httpcloak knows where Chrome puts `authorization` vs `accept` and so on). ```go resp, err := sess.Do(ctx, &httpcloak.Request{ Method: "GET", URL: "https://httpbin.org/headers", Headers: map[string][]string{ "Authorization": {"Bearer abc123"}, "X-Request-Id": {"42"}, }, }) ``` ```python r = session.get( "https://httpbin.org/headers", headers={"Authorization": "Bearer abc123", "X-Request-Id": "42"}, ) ``` ```javascript const r = await session.get("https://httpbin.org/headers", { headers: { Authorization: "Bearer abc123", "X-Request-Id": "42" }, }); ``` ```csharp var r = session.Get("https://httpbin.org/headers", headers: new() { ["Authorization"] = "Bearer abc123", ["X-Request-Id"] = "42", }); ``` Need to override the preset's header order entirely? That's a separate concern (`SetHeaderOrder` on the session, see [Reference: Options](/reference/options)). ## Cookies The session has a built-in cookie jar. It captures `Set-Cookie` from every response and replays the right cookies on subsequent requests, scoped to domain and path the way browsers do. It just works out of the box. No opt-in needed. If you want to inspect or seed the jar, see [Cookies and State](/cookies-and-state). If your app already manages cookies somewhere else (shared store across many sessions, or you're proxying for another tool that owns the jar), turn the internal jar off: ```go sess := httpcloak.NewSession("chrome-latest", httpcloak.WithoutCookieJar()) ``` ```python session = httpcloak.Session(preset="chrome-latest", without_cookie_jar=True) ``` ```javascript const session = new Session({ preset: "chrome-latest", withoutCookieJar: true }); ``` ```csharp using var session = new Session(preset: "chrome-latest", withoutCookieJar: true); ``` With the jar off, `Set-Cookie` values from responses aren't stored, and nothing gets auto-injected on later requests. You handle the `Cookie` header yourself per-request. See [Disabling the Cookie Jar](/cookies-and-state/disabling-cookie-jar) for the full pattern. ## Local source address Got an IPv6 prefix routed to your host (cheap rotating egress) or multiple IPv4 addresses on one box? You can pin a session to a specific source IP. Linux freebind kicks in automatically so you don't have to configure each address on the interface, which is a low-key huge time saver. ```go sess := httpcloak.NewSession("chrome-latest", httpcloak.WithLocalAddress("2001:db8::1234"), ) ``` ```python session = httpcloak.Session(preset="chrome-latest", local_address="2001:db8::1234") ``` ```javascript const session = new Session({ preset: "chrome-latest", localAddress: "2001:db8::1234" }); ``` ```csharp using var session = new Session(preset: "chrome-latest", localAddress: "2001:db8::1234"); ``` Works for IPv4 too. For rotation patterns and freebind details, see [Source Address Binding](/proxies/source-address-binding). ## What's not on this page - Proxies (HTTP CONNECT, SOCKS5, MASQUE, split TCP/UDP): see [Proxies](/proxies). - Fingerprint customization (custom JA3, Akamai shorthand, JSON presets): see [Fingerprinting](/fingerprinting). - Advanced TLS knobs (ECH, key logging, session resumption): see [Advanced TLS](/advanced-tls). - Streaming uploads/downloads, multipart, redirect history details: see [Requests and Responses](/requests-and-responses). :::info Full option list What's here is the everyday set. For everything else (`WithForceHTTP3`, `WithKeyLogFile`, `WithECHFrom`, `WithCustomFingerprint`, `WithSessionCache`, and friends), see [Reference: Options](/reference/options). ::: --- # Proxies httpcloak speaks HTTP CONNECT, SOCKS5, SOCKS5 with UDP, and MASQUE. Pick whichever one your upstream actually offers and your protocol mix needs. ## In this section - [Overview](./overview): when to pick what, what each protocol carries, what it solves. - [HTTP CONNECT](./http-connect): the classic. Plain HTTPS tunneling. - [SOCKS5](./socks5): the residential-provider workhorse, with auth. - [SOCKS5 UDP](./socks5-udp): UDP ASSOCIATE for pushing HTTP/3 through SOCKS5. - [MASQUE](./masque): HTTP/3 CONNECT-UDP. QUIC inside QUIC. - [Source Address Binding](./source-address-binding): pin every dial to a local IP you pick. --- # Overview Four proxy protocols. Pick the one that matches your upstream and the kind of traffic you're routing. ## What we support | Type | URL scheme | Carries | Auth | Best for | |---|---|---|---|---| | HTTP CONNECT | `http://`, `https://` | TCP (HTTP/1.1, HTTP/2) | Basic | The classic. Datacenter proxies, corporate egress, anything fronting as a normal HTTP proxy. | | SOCKS5 | `socks5://`, `socks5h://` | TCP (HTTP/1.1, HTTP/2) | none, user/pass | Residential providers default to this. BrightData, Smartproxy, Oxylabs, SOAX. | | SOCKS5 with UDP ASSOCIATE | `socks5://` (UDP slot) | UDP (HTTP/3 over QUIC) | none, user/pass | Routing H3 through a SOCKS5 server that supports UDP relay. Less common, not every provider does it. | | MASQUE (CONNECT-UDP) | `masque://`, `https://` | UDP inside HTTP/3 | Basic | The new kid. Tunnels QUIC inside QUIC. Cloudflare WARP and a few residential providers ship this for H3. | If you've never thought about which to grab: HTTP CONNECT for HTTP/1.1 and HTTP/2, SOCKS5 if your residential provider handed you a SOCKS5 endpoint, and add a UDP proxy (SOCKS5 UDP or MASQUE) only when you actually want HTTP/3 to ride through the proxy too. ## Split-config: TCP and UDP separately You can configure the TCP proxy and the UDP proxy independently. Two options on the same session: - `WithSessionTCPProxy(url)` sends HTTP/1.1 and HTTP/2 through this proxy. - `WithSessionUDPProxy(url)` sends HTTP/3 (QUIC) through this proxy. You'd want this when your TCP proxy can't do UDP. Common shapes: ```go // HTTP CONNECT for H1/H2 + MASQUE for H3 s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("http://user:pass@proxy.example.com:8080"), httpcloak.WithSessionUDPProxy("masque://user:pass@proxy.example.com:443"), ) // SOCKS5 for everything (only works if SOCKS5 server supports UDP ASSOCIATE) s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("socks5://user:pass@proxy.example.com:1080"), httpcloak.WithSessionUDPProxy("socks5://user:pass@proxy.example.com:1080"), ) // HTTP CONNECT only, kill H3 because no UDP route s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("http://user:pass@proxy.example.com:8080"), httpcloak.WithDisableHTTP3(), ) ``` Set only `WithSessionTCPProxy` and skip the UDP slot? H3 will dial direct. Most of the time that's fine. If your network blocks outbound UDP/443, pair it with `WithDisableHTTP3()` so the session sticks to H1/H2. There's also `WithSessionProxy(url)`, the old single-proxy option. It still works and applies the URL to both slots based on scheme. New code should prefer the split form, it's just clearer about what's going where. ## Auth Auth lives in the URL. All three forms work: - `http://user:pass@host:port` - `socks5://user:pass@host:port` - `masque://user:pass@host:port` For HTTP CONNECT and MASQUE, this becomes a `Proxy-Authorization: Basic ` header. For SOCKS5 it goes through RFC 1929 username/password sub-negotiation. URL-encode the password if it has special chars, that's standard URL parsing. ## Source-address binding Independent of any proxy choice, you can pin every dial to a specific local IP. Use `WithLocalAddress("203.0.113.10")` or `WithLocalAddrIP(net.IP)` and httpcloak binds every outgoing socket (direct or through a proxy) to that address. On Linux it sets `IP_FREEBIND` so addresses from a routed prefix that aren't on any interface still work without `CAP_NET_ADMIN`. See [Source Address Binding](./source-address-binding) for the full shape, IPv6 prefix rotation tricks, and platform notes. ## Picking the right one A rough decision tree: - Datacenter or corporate proxy gave you a host:port and HTTP basic auth: HTTP CONNECT. - Residential provider gave you a SOCKS5 endpoint: [SOCKS5](./socks5). - Same residential provider, you want H3 to also go through the proxy and they advertise UDP support: [SOCKS5 UDP](./socks5-udp). - You want H3 traffic specifically tunneled through Cloudflare WARP or a custom MASQUE server: [MASQUE](./masque). - You want a single source IP no matter the proxy choice: [Source Address Binding](./source-address-binding). ## Bindings Same options exist in Python (`tcp_proxy=`, `udp_proxy=`, `local_address=`), Node.js (`tcpProxy`, `udpProxy`, `localAddress`), and .NET (`tcpProxy:`, `udpProxy:`, `localAddress:`). The chapters that follow show 4-language tabs wherever the API differs. --- # HTTP CONNECT The OG HTTP proxy. Your client opens TCP to the proxy, sends `CONNECT host:port HTTP/1.1`, the proxy comes back with `200 Connection established`, and from there the socket is just a raw tunnel. Your real TLS handshake then runs through that tunnel like the proxy isn't even there. This is what HTTPS-through-an-HTTP-proxy actually looks like on the wire. Squid, mitmproxy in upstream mode, every datacenter proxy provider, almost every corporate egress proxy: all HTTP CONNECT. ## URL shape ``` http://user:pass@proxy.example.com:8080 https://user:pass@proxy.example.com:8443 ``` Use `https://` when the connection from your client to the proxy itself should be TLS. It's not super common, but some providers offer it. The inner request is always TLS regardless, because that's the target site's TLS, decrypted only at the target. ## Setting it up ```go package main import ( "context" "fmt" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("http://user:pass@proxy.example.com:8080"), ) defer s.Close() resp, err := s.Get(context.Background(), "https://httpbin.org/ip") if err != nil { panic(err) } fmt.Println(resp.StatusCode, string(resp.Body)) // {"origin": ""} } ``` ```python import httpcloak with httpcloak.Session( preset="chrome-latest", tcp_proxy="http://user:pass@proxy.example.com:8080", ) as s: r = s.get("https://httpbin.org/ip") print(r.status_code, r.text) # {"origin": ""} ``` ```js const { Session } = require('httpcloak'); const s = new Session({ preset: 'chrome-latest', tcpProxy: 'http://user:pass@proxy.example.com:8080', }); const r = await s.get('https://httpbin.org/ip'); console.log(r.statusCode, r.body); s.close(); ``` ```csharp using HttpCloak; using var s = new Session( preset: "chrome-latest", tcpProxy: "http://user:pass@proxy.example.com:8080"); var r = await s.GetAsync("https://httpbin.org/ip"); Console.WriteLine($"{r.StatusCode} {r.Body}"); ``` ## What's on the wire A normal request through an HTTP CONNECT proxy goes: 1. TCP open to `proxy.example.com:8080`. 2. Client sends `CONNECT httpbin.org:443 HTTP/1.1` plus `Proxy-Authorization: Basic ` and a host header. 3. Proxy replies `HTTP/1.1 200 Connection established`. 4. Client sends the TLS ClientHello on the same socket. From here the proxy is just forwarding bytes. 5. TLS handshake completes against `httpbin.org`. Client sends `GET /ip`. That's two round-trips before TLS even starts (TCP handshake, then CONNECT exchange), then one or two more for TLS depending on resumption. ## Saving an RTT with speculative TLS httpcloak can pipeline the CONNECT request with the inner ClientHello. Same socket, both writes coalesced before any read. Saves one full round-trip on every fresh proxied connection. ```go s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("http://user:pass@proxy.example.com:8080"), httpcloak.WithEnableSpeculativeTLS(), ) ``` It's off by default because some proxies (older Squid configs, a handful of debugging tools) freak out when bytes show up before the 200 is fully read. Test it on your provider first. Full details and trade-offs in [Speculative TLS](/advanced-tls/speculative-tls). ## H3 with an HTTP CONNECT TCP proxy HTTP CONNECT only carries TCP. If you set just `WithSessionTCPProxy` with an HTTP URL, H3 will dial direct to the target (no proxy on UDP). Three options: - Let H3 dial direct (the default). Fine on most networks. - Add a UDP proxy: `WithSessionUDPProxy("masque://...")` or a SOCKS5 endpoint that supports UDP ASSOCIATE. - Disable H3: `WithDisableHTTP3()` so the session sticks to H1/H2 and everything goes through the HTTP CONNECT proxy. ## Auth Basic auth is handled for you when the credentials are in the URL. httpcloak base64-encodes `user:pass` and adds `Proxy-Authorization: Basic ...` to the CONNECT request. Password has special characters? URL-encode them: ``` http://user:p%40ss%21@proxy.example.com:8080 ``` (That's `p@ss!`.) ## Common errors - `407 Proxy Authentication Required`: credentials missing or wrong. Check the URL has `user:pass@`. - `403 Forbidden`: proxy reached you but rejected the target. Some providers block specific destinations. Try a different target to confirm. - `502 Bad Gateway`: proxy couldn't reach the target. Often the proxy's upstream having a bad day. - `failed to resolve proxy host ...`: your DNS can't resolve the proxy hostname itself. Most often a typo. ## Provider quirks Some HTTP CONNECT providers are picky about the order or presence of headers in the CONNECT request. httpcloak sends a minimal CONNECT (method line, Host header, optional Proxy-Authorization). If a provider needs a specific User-Agent or extra header on the CONNECT line itself, that's not currently configurable. File an issue with the provider name if you hit this. --- # SOCKS5 SOCKS5 is the residential-proxy workhorse. The client connects to the SOCKS5 server, runs a short version + auth handshake, asks the server to CONNECT to the target host, and from there the socket is a TCP tunnel. httpcloak runs its real TLS handshake through that tunnel. It's basically the SOCKS5 cousin of HTTP CONNECT, just with a binary handshake and an auth scheme that does username/password as a proper sub-protocol. :::info SOCKS5 is the residential workhorse. Most providers (BrightData, Smartproxy, Oxylabs, SOAX, IPRoyal etc) ship SOCKS5 endpoints by default. If you bought "rotating residential proxies" from someone in the last few years, what they handed you was almost certainly SOCKS5. ::: ## URL shapes ``` socks5://proxy.example.com:1080 socks5://user:pass@proxy.example.com:1080 socks5h://proxy.example.com:1080 ``` `socks5` and `socks5h` both work. httpcloak always sends hostname targets to the proxy as a SOCKS5 domain ATYP (address type 3), which means DNS resolution of the *target* happens at the proxy. The *proxy's own hostname* is always resolved client-side, that part you can't avoid. If your URL has an IP literal as the target host (rare, you'd build that yourself), httpcloak sends an IPv4 or IPv6 ATYP instead. ## Setting it up ```go package main import ( "context" "fmt" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("socks5://user:pass@proxy.example.com:1080"), ) defer s.Close() resp, err := s.Get(context.Background(), "https://httpbin.org/ip") if err != nil { panic(err) } fmt.Println(resp.StatusCode, string(resp.Body)) } ``` ```python import httpcloak with httpcloak.Session( preset="chrome-latest", tcp_proxy="socks5://user:pass@proxy.example.com:1080", ) as s: r = s.get("https://httpbin.org/ip") print(r.status_code, r.text) ``` ```js const { Session } = require('httpcloak'); const s = new Session({ preset: 'chrome-latest', tcpProxy: 'socks5://user:pass@proxy.example.com:1080', }); const r = await s.get('https://httpbin.org/ip'); console.log(r.statusCode, r.body); s.close(); ``` ```csharp using HttpCloak; using var s = new Session( preset: "chrome-latest", tcpProxy: "socks5://user:pass@proxy.example.com:1080"); var r = await s.GetAsync("https://httpbin.org/ip"); Console.WriteLine($"{r.StatusCode} {r.Body}"); ``` ## Auth schemes httpcloak negotiates the auth method based on what's in the URL. - No `user:pass` in the URL: client offers `0x00` (NO AUTHENTICATION REQUIRED) only. If the proxy demands auth it'll fail the handshake. - `user:pass` in the URL: client offers both no-auth and `0x02` (USERNAME/PASSWORD, RFC 1929). Server picks one. If it picks username/password, httpcloak runs the sub-negotiation. So an authenticated URL works fine against an open proxy too, the server just picks no-auth and the credentials sit unused. URL-encode special characters in the password the same way you'd do it for HTTP CONNECT: ``` socks5://user:p%40ss%21@proxy.example.com:1080 ``` GSSAPI (auth method `0x01`) isn't supported. If your provider needs it, raise an issue. ## SOCKS5 vs SOCKS5h `socks5h://` is a curl-ism that means "delegate DNS to the proxy". httpcloak treats both schemes identically because it always sends hostname targets as a SOCKS5 domain ATYP, which is exactly the "DNS-at-proxy" behavior. The scheme suffix is accepted but doesn't change anything you'd notice on the wire. If you really want to force client-side DNS for the target, you'd resolve the hostname yourself before building the URL. Usually not what you want with residential providers, since the proxy's DNS view is part of why you're using it. ## H3 through SOCKS5 A vanilla `WithSessionTCPProxy("socks5://...")` only routes TCP. H3 (QUIC over UDP) will dial direct to the target. If you want H3 through the SOCKS5 server too, the server needs to support UDP ASSOCIATE and you have to wire a UDP proxy. See [SOCKS5 UDP](./socks5-udp). ## Common errors - `SOCKS5 handshake failed: failed to read auth response`: usually the proxy closed the socket. Check the URL host/port and credentials. - `proxy rejected all authentication methods`: server didn't accept no-auth or user/pass. You probably need to add credentials. - `authentication failed: invalid credentials`: self-explanatory, typo or bad creds. - `CONNECT failed: connection refused (reply=5)`: target host or port refused the connection. Try a different target. - `CONNECT failed: host unreachable (reply=4)`: target DNS resolved at the proxy to something unroutable, or the proxy can't reach it. The `reply=N` codes follow RFC 1928 section 6, useful when grepping provider docs. ## Source-IP binding combined with SOCKS5 `WithLocalAddress` works with SOCKS5 too. The local address binds the socket from your machine to the SOCKS5 server. The proxy still picks its own egress IP for the upstream side, you don't get to influence that from the client. Use this when your machine has multiple public IPs and you want to route the SOCKS5 control connection out a specific one. --- # SOCKS5 UDP SOCKS5 has a `UDP ASSOCIATE` command (RFC 1928 section 4) that lets the client send and receive UDP datagrams through the proxy. httpcloak uses this to push HTTP/3 (QUIC over UDP) through a SOCKS5 server. The flow is two-part: 1. A TCP control connection to the SOCKS5 server, doing the normal greeting + auth, then sending a UDP ASSOCIATE request. 2. The proxy replies with a relay address (a UDP host:port). The client opens a local UDP socket and sends every datagram to that relay address, wrapped in a small SOCKS5 UDP header carrying the real target. Replies come back the same way. QUIC packets get wrapped, sent through the relay, unwrapped on the proxy, forwarded to the target, replies wrapped on the way back. To QUIC it just looks like a regular UDP socket. ## Wiring it up You need both a TCP proxy (for the H1/H2 path) and a UDP proxy (for the H3 path) on the same SOCKS5 endpoint. Set both: ```go package main import ( "context" "fmt" "github.com/sardanioss/httpcloak" ) func main() { proxyURL := "socks5://user:pass@proxy.example.com:1080" s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy(proxyURL), httpcloak.WithSessionUDPProxy(proxyURL), httpcloak.WithForceHTTP3(), ) defer s.Close() resp, err := s.Get(context.Background(), "https://httpbin.org/ip") if err != nil { panic(err) } fmt.Println(resp.StatusCode, string(resp.Body)) } ``` ```python import httpcloak proxy = "socks5://user:pass@proxy.example.com:1080" with httpcloak.Session( preset="chrome-latest", tcp_proxy=proxy, udp_proxy=proxy, http_version="h3", ) as s: r = s.get("https://httpbin.org/ip") print(r.status_code, r.text) ``` ```js const { Session } = require('httpcloak'); const proxy = 'socks5://user:pass@proxy.example.com:1080'; const s = new Session({ preset: 'chrome-latest', tcpProxy: proxy, udpProxy: proxy, httpVersion: 'h3', }); const r = await s.get('https://httpbin.org/ip'); console.log(r.statusCode, r.body); s.close(); ``` ```csharp using HttpCloak; const string proxy = "socks5://user:pass@proxy.example.com:1080"; using var s = new Session( preset: "chrome-latest", tcpProxy: proxy, udpProxy: proxy, httpVersion: "h3"); var r = await s.GetAsync("https://httpbin.org/ip"); Console.WriteLine($"{r.StatusCode} {r.Body}"); ``` You can also mix: HTTP CONNECT for TCP, SOCKS5 UDP ASSOCIATE for the UDP slot. That's a legal combo as long as both proxies actually exist and reach the same target. :::warning UDP ASSOCIATE is not universal Not every SOCKS5 server supports `UDP_ASSOCIATE`. The RFC says servers MAY support it, not MUST. Plenty of residential providers don't, and on a load-balanced endpoint you might land on a server that doesn't even when others on the same hostname do. If a server doesn't speak UDP, it replies with `reply=7` (Command not supported) or `reply=1` (general SOCKS server failure). httpcloak retries up to 5 times on `reply=1` because that's the typical load-balancer-hits-a-bad-server symptom. After that it gives up. Test before trusting it for H3 routing. Simplest test: configure `udp_proxy`, force H3 against `https://httpbin.org/ip`. If it errors with "UDP ASSOCIATE failed" you don't have UDP support. ::: ## What the SOCKS5 UDP header looks like Every datagram sent to the relay is prefixed with: ``` +----+------+------+----------+----------+----------+ |RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA | +----+------+------+----------+----------+----------+ | 2 | 1 | 1 | Variable | 2 | Variable | +----+------+------+----------+----------+----------+ ``` `FRAG` is always 0. httpcloak doesn't fragment, and it refuses to read fragmented datagrams from the proxy because reassembly is messy and basically never happens in practice. The address is the *target* host:port. ATYP follows the same enum as the TCP path: 1 IPv4, 3 domain, 4 IPv6. ## Keepalive on the control channel Per RFC 1928, when the TCP control connection closes, the UDP relay goes away with it. httpcloak enables TCP keepalive on the control connection (15s period) and runs a goroutine that reads from the control socket so dropped connections get noticed quickly. For long-lived QUIC connections this matters. If you start an H3 request and the SOCKS5 control channel dies 30 seconds in, your QUIC connection silently stops getting packets through. That's the kind of hang you'd debug for an hour before realizing the control socket dropped. ## Common errors - `UDP ASSOCIATE failed: command not supported`: server doesn't speak UDP relay. Pick a different provider or a different server. - `UDP ASSOCIATE failed: general SOCKS server failure`: usually means you hit a load-balanced backend that doesn't do UDP. httpcloak retries up to 5 times before giving up. - `fragmented packets not supported (frag=N)`: a proxy is sending fragmented datagrams, which is rare and usually a misconfiguration. - QUIC handshake timeout after UDP ASSOCIATE succeeds: the relay exists but isn't actually forwarding packets. Some proxies advertise UDP and then silently drop everything. Annoying but real. Try a different endpoint. ## When MASQUE is the better answer If your provider also offers a MASQUE endpoint, that's usually a smoother way to tunnel H3. SOCKS5 UDP works but the ecosystem is patchy and the per-datagram header overhead is real. See [MASQUE](./masque) for the H3-native alternative. --- # MASQUE MASQUE is the new kid: HTTP/3's answer to "I want to tunnel UDP through a proxy". The client opens an HTTP/3 connection to the proxy, sends an Extended CONNECT request with `:protocol = connect-udp` against the well-known path, and on success uses HTTP/3 Datagrams (RFC 9297) to push UDP payload bytes back and forth. In practice for httpcloak, the inner UDP payload is QUIC packets aimed at the real target. So you get QUIC inside QUIC. The outer QUIC encrypts your tunnel to the proxy, the inner QUIC encrypts your traffic to the target. The relevant RFCs: - RFC 9298: CONNECT-UDP (the method, the well-known path, the framing) - RFC 9297: HTTP/3 Datagrams (the carrier for inner UDP) - RFC 9484: the MASQUE WG output document tying it together ## URL shapes ``` masque://proxy.example.com:443 masque://user:pass@proxy.example.com:443 https://user:pass@proxy.example.com:443 # auto-detected for known providers ``` `masque://` is just a scheme hint. Internally it gets normalized to `https://` because the proxy connection is plain HTTPS-over-H3. If the hostname matches a known MASQUE provider (BrightData, Oxylabs, Smartproxy, SOAX), `https://` is auto-detected as MASQUE. For any other provider use `masque://` explicitly so httpcloak doesn't try to treat the URL as a regular HTTPS proxy. Default port is 443. The proxy must speak HTTP/3 with Extended CONNECT and HTTP/3 Datagrams enabled. ## Setting it up ```go package main import ( "context" "fmt" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionUDPProxy("masque://user:pass@proxy.example.com:443"), httpcloak.WithForceHTTP3(), ) defer s.Close() resp, err := s.Get(context.Background(), "https://httpbin.org/ip") if err != nil { panic(err) } fmt.Println(resp.StatusCode, string(resp.Body)) } ``` ```python import httpcloak with httpcloak.Session( preset="chrome-latest", udp_proxy="masque://user:pass@proxy.example.com:443", http_version="h3", ) as s: r = s.get("https://httpbin.org/ip") print(r.status_code, r.text) ``` ```js const { Session } = require('httpcloak'); const s = new Session({ preset: 'chrome-latest', udpProxy: 'masque://user:pass@proxy.example.com:443', httpVersion: 'h3', }); const r = await s.get('https://httpbin.org/ip'); console.log(r.statusCode, r.body); s.close(); ``` ```csharp using HttpCloak; using var s = new Session( preset: "chrome-latest", udpProxy: "masque://user:pass@proxy.example.com:443", httpVersion: "h3"); var r = await s.GetAsync("https://httpbin.org/ip"); Console.WriteLine($"{r.StatusCode} {r.Body}"); ``` If you also need H1/H2 to go through a proxy, pair `udp_proxy` with `tcp_proxy`. The common shape: HTTP CONNECT for TCP, MASQUE for UDP. ## What's on the wire 1. UDP socket open, QUIC handshake to `proxy.example.com:443`. ALPN is `h3`, datagram support negotiated, Extended CONNECT confirmed via `SETTINGS_ENABLE_CONNECT_PROTOCOL`. 2. Client opens an HTTP/3 request stream and sends: ``` :method = CONNECT :protocol = connect-udp :scheme = https :authority = proxy.example.com:443 :path = /.well-known/masque/udp/// capsule-protocol = ?1 proxy-authorization = Basic # if creds present ``` 3. Proxy replies with a 2xx status. The request stream stays open. 4. Every QUIC packet for the real target gets wrapped in an HTTP/3 Datagram with context ID 0 (per RFC 9298), sent on the same QUIC connection. Replies come back the same way. The inner QUIC connection runs an entirely separate handshake against the actual target host. The proxy can't read it. ## Provider auto-detection httpcloak ships a small list of known MASQUE-capable providers in [proxy/masque_providers.go](https://github.com/sardanioss/httpcloak/blob/main/proxy/masque_providers.go). If your `https://` URL matches one of those hostnames it gets handled as MASQUE automatically. For any other provider, use `masque://` explicitly. That's the unambiguous form. You can extend the list at runtime: ```go import "github.com/sardanioss/httpcloak/proxy" proxy.AddMASQUEProvider("my-custom-masque.example.com") ``` After that, `https://my-custom-masque.example.com:443` will be treated as MASQUE. Process-wide, applied immediately to subsequent sessions. ## QUIC config knobs that matter - `EnableDatagrams` is forced on for the proxy connection. Required. - `InitialPacketSize` defaults to 1350 to leave room for the outer QUIC frame plus inner QUIC's ~1200 MTU. Don't set it lower. - The browser preset's QUIC fingerprint applies to the outer connection (the one to the proxy). The inner connection to the target also uses the preset, so the target sees a normal browser QUIC handshake. ## Common errors - `proxy doesn't support Extended CONNECT`: the proxy isn't a MASQUE server. You're hitting a regular HTTPS endpoint or an H3 server that hasn't enabled Extended CONNECT. Check the URL. - `proxy doesn't support HTTP/3 Datagrams`: same family, datagrams weren't negotiated. Provider misconfig or just not a MASQUE endpoint. - `proxy responded with 407`: auth failed. Check `user:pass` in the URL. - `proxy responded with 403`: target blocked by the proxy. - `proxy responded with 502/503`: proxy can't reach the target over UDP, or the target's QUIC ALPN handshake failed. ## When to pick MASQUE over SOCKS5 UDP MASQUE was designed from scratch for tunneling UDP. The framing is clean, congestion control is the outer QUIC's problem, and providers that ship it usually take it seriously. SOCKS5 UDP_ASSOCIATE works but the ecosystem support is patchy and the framing is per-datagram. If your provider offers both, prefer MASQUE for H3. --- # Source Address Binding Sometimes you need every outgoing socket to leave from a specific local IP. Reasons: - Your machine has multiple public IPs and you want to pick one. - You've got a routed IPv6 prefix (`/64` or wider) and want to rotate source IPs per request out of a huge pool. - Audit / compliance requires a known egress IP. - You're testing IPv6 behavior on a dual-stack box. httpcloak gives you two options for this. They both set the same internal field, pick whichever ergonomic fits your code. ## The options `WithLocalAddress(string)` takes a string IP. Both v4 and v6 work: ```go httpcloak.WithLocalAddress("192.168.1.100") httpcloak.WithLocalAddress("2001:db8::dead:beef") ``` `WithLocalAddrIP(net.IP)` takes a parsed `net.IP`. Useful when you've got an IP from a pool or a randomizer and you don't want to round-trip through a string. Nil is a no-op so you can safely write `opts = append(opts, httpcloak.WithLocalAddrIP(maybeNil))` without clobbering a previously-set address. ```go ip := pickRandomFromPool() // returns net.IP httpcloak.WithLocalAddrIP(ip) ``` There's also `WithSessionPreferIPv4()`, which is unrelated to local binding but commonly comes up next to it. It opts the dialer out of Happy Eyeballs and forces v4 lookups. Use it on networks where IPv6 is half-broken. ## What it does at the socket layer Every outgoing socket (direct dial, dial-to-proxy, UDP for QUIC) gets `LocalAddr` set to the chosen IP with port 0 (kernel picks the ephemeral port). The kernel then tries to bind that source address before the connect. On Linux, httpcloak also calls `setsockopt(IP_FREEBIND)` and `setsockopt(IPV6_FREEBIND)` on the raw socket before the bind. Why this matters next. ## Linux `IP_FREEBIND`: bind addresses you don't "own" By default a Linux box won't let you bind to an IP that isn't configured on any of its interfaces. You'd get `EADDRNOTAVAIL`. `IP_FREEBIND` (and the IPv6 equivalent) skip that check. The kernel trusts that you know what you're doing. This is the magic that makes IPv6 prefix rotation cheap. Your hoster routes a `/64` (or `/56`, or `/48`) to your box. You don't have to configure 18 quintillion addresses on the interface. You just bind to whichever address you want from the prefix and `IP_FREEBIND` lets the bind succeed. Outgoing packets carry that source address, return packets get routed back because the upstream router knows the prefix is yours. Pretty slick. httpcloak applies `FREEBIND` unconditionally on Linux. Failures get silently ignored. If the kernel rejects it, the bind would have worked anyway because the address was locally configured, and we don't want to fail the simple case. :::tip IPv6 /64 rotation If you have a `/64` (or wider) routed to your box and want a fresh source IP per request, generate a random suffix and pass it to `WithLocalAddrIP`. Linux freebind handles the rest, no `ip addr add` required. ::: ## Permissions on Linux `IP_FREEBIND` works without root in two cases: - Per-process: granted via `CAP_NET_ADMIN` (rare for userland). - System-wide: `sysctl net.ipv4.ip_nonlocal_bind=1` and the IPv6 equivalent `net.ipv6.ip_nonlocal_bind=1`. Most production setups go with the sysctl. Drop this in `/etc/sysctl.d/`: ``` net.ipv4.ip_nonlocal_bind = 1 net.ipv6.ip_nonlocal_bind = 1 ``` Without one of these, binding to a non-configured address still fails even with `FREEBIND` set. The setsockopt is necessary but not sufficient on stock kernels. ## Platform notes - **Linux**: Full support. `IP_FREEBIND` + sysctl as above. - **macOS / Darwin**: Bind works for addresses configured on an interface. Non-local bind isn't a thing the way Linux does it. - **Windows**: Same as macOS. - **Other Unix**: `freebind_other.go` is a no-op. The bind goes through but non-local addresses will fail at the kernel. If you run on Linux and your code is portable, design for the Linux behavior and accept the other platforms as best-effort. ## Examples ### Pin to a specific IPv4 ```go s := httpcloak.NewSession("chrome-latest", httpcloak.WithLocalAddress("203.0.113.10"), ) ``` ```python s = httpcloak.Session( preset="chrome-latest", local_address="203.0.113.10", ) ``` ```js const s = new Session({ preset: 'chrome-latest', localAddress: '203.0.113.10', }); ``` ```csharp using var s = new Session( preset: "chrome-latest", localAddress: "203.0.113.10"); ``` ### Bind to a specific IPv6 ```go import "net" s := httpcloak.NewSession("chrome-latest", httpcloak.WithLocalAddrIP(net.ParseIP("2001:db8::1")), ) ``` ### Rotating IPv6 from a /64 ```go import ( "crypto/rand" "net" ) func randomFromPrefix64(prefix net.IP) net.IP { suffix := make([]byte, 8) _, _ = rand.Read(suffix) out := make(net.IP, 16) copy(out[:8], prefix.To16()[:8]) copy(out[8:], suffix) return out } prefix := net.ParseIP("2001:db8:abcd:1234::") // your routed /64 ip := randomFromPrefix64(prefix) s := httpcloak.NewSession("chrome-latest", httpcloak.WithLocalAddrIP(ip), ) defer s.Close() resp, _ := s.Get(ctx, "https://httpbin.org/ip") // returns the random v6 address you picked ``` You'd run that for each fresh request (a new session per IP, or pool the sessions by IP). The session itself caches one local address for its lifetime. Rotation happens at session-construction time. ### Combined with a proxy Local binding and proxy options compose. The local address binds the *client to proxy* socket; the proxy still picks its own egress for the target connection. ```go s := httpcloak.NewSession("chrome-latest", httpcloak.WithSessionTCPProxy("socks5://user:pass@proxy.example.com:1080"), httpcloak.WithLocalAddress("203.0.113.10"), ) ``` This makes sense when your machine has multiple egress IPs and you want the SOCKS5 control connection to leave from a known one (routing, firewall ACLs, etc). --- # Cookies & State The session jar handles cookies for you. This section covers how it works, when you'd want to switch it off, and how to hand-roll a `Cookie` header for one-off calls. ## In this section - [Cookie Jar](./cookie-jar): how the internal jar works, what gets stored, when it sends what - [Disabling the Cookie Jar](./disabling-cookie-jar): WithoutCookieJar() and when to actually do that - [Per-Request Cookies](./per-request-cookies): ad-hoc Cookie headers for one-off calls - [Domain and Path Matching](./domain-and-path-matching): the quirks of cookies matching the next URL --- # Cookie Jar The cookie jar is on by default. It stores `Set-Cookie` values, replays them on matching follow-up requests, just like a browser tab does. You don't have to flip anything on. It's already running. ## What gets stored When a response comes back with `Set-Cookie` headers, httpcloak parses each one and saves the full attribute set: - `name` and `value` - `domain` (with the host-only flag tracked separately) - `path` - `expires` and `max-age` - `secure` - `httpOnly` - `sameSite` The jar also tracks creation time so cookies with longer paths and older creation timestamps come first when building the `Cookie` header. That's the RFC 6265 sort order browsers use. ## When the jar sends what On the next request, the jar walks its stored cookies and picks the ones that match: - the request **host** (host-only cookies need an exact match, domain cookies match the domain and any subdomain) - the request **path** (cookie path must be a prefix of the request path with a `/` boundary) - the request **scheme** (secure cookies only ride HTTPS) - the **expiry** (anything past its expiry gets skipped) The matches get glued together into a single `Cookie:` header and sent. See [Domain and Path Matching](./domain-and-path-matching) for the full rules and the gotchas. ## Lifecycle Cookies live in memory for as long as the `Session` lives. Close the session and the jar goes with it. If you want cookies to survive across processes, use `Save()` and `LoadSession()`. They serialize the jar (along with TLS resumption tickets and a few other bits) to a file you can reload later. See [Session save & restore](/connection-lifecycle/session-save-restore) for the full flow. ## Quick example The example below sets a cookie via `httpbin.org/cookies/set`, then reads `httpbin.org/cookies` to show the jar replayed it on the second request. ```go package main import ( "context" "fmt" "io" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-146") defer s.Close() ctx := context.Background() r1, _ := s.Get(ctx, "https://httpbin.org/cookies/set?demo=hello") r1.Close() r2, _ := s.Get(ctx, "https://httpbin.org/cookies") body, _ := io.ReadAll(r2.Body) r2.Close() fmt.Println(string(body)) // {"cookies": {"demo": "hello"}} } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-146") as s: s.get("https://httpbin.org/cookies/set?demo=hello") r = s.get("https://httpbin.org/cookies") print(r.json()) # {'cookies': {'demo': 'hello'}} ``` ```js const httpcloak = require("httpcloak"); const s = new httpcloak.Session({ preset: "chrome-146" }); try { await s.get("https://httpbin.org/cookies/set?demo=hello"); const r = await s.get("https://httpbin.org/cookies"); console.log(r.json()); // { cookies: { demo: 'hello' } } } finally { s.close(); } ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-146"); s.Get("https://httpbin.org/cookies/set?demo=hello"); var r = s.Get("https://httpbin.org/cookies"); Console.WriteLine(r.Text); // {"cookies": {"demo": "hello"}} ``` ## Inspecting the jar The session exposes the current jar contents so you can poke at it: - `GetCookies()` returns the full list with domain, path, expiry, flags - `SetCookie(...)` adds or updates a cookie programmatically - `DeleteCookie(name, domain)` removes one (pass empty domain to wipe across all domains) - `ClearCookies()` empties the jar Handy for tests, debugging, or when you want to seed the jar before the first request. :::info DoStream parity `DoStream()` also pulls cookies out of streamed responses (fixed in 1.6.6). Older versions you'll find in tutorials online didn't, so if you copy old code, double-check the version. Streaming and non-streaming requests now feed the same jar. ::: ## What the jar does NOT do - It doesn't enforce `__Host-` and `__Secure-` cookie name prefixes with the strict RFC checks. The flags (`Secure`, host-only) are still respected, but the prefix rules aren't enforced separately. - It doesn't do anything with `SameSite` other than store it. There's no cross-site request tracking, so cookies always go out on requests you make. - It doesn't garbage-collect expired cookies on every read. They get filtered at send time and during `ClearExpired()`. If you keep a long-lived session and want a clean snapshot, call `ClearExpired()` yourself. ## When you don't want the jar If you'd rather drive cookies yourself, drop the jar entirely with `WithoutCookieJar()`. See [Disabling the Cookie Jar](./disabling-cookie-jar). --- # Disabling the Cookie Jar Sometimes you don't want the session managing cookies for you. Maybe you've got a database acting as your single source of truth. Maybe you want every request fully isolated. Or maybe you're debugging and the auto-replay is getting in the way. `WithoutCookieJar()` (added in 1.6.6) flips the jar off. With it set: - `Set-Cookie` headers from responses are **not** stored - The jar is **not** consulted when building the next request's `Cookie` header - `GetCookies()` returns an empty list Caller-provided `Cookie` headers still pass through untouched. Only the auto-injection from the internal jar is suppressed. ## When to actually do this A few real reasons to turn it off: - **You manage cookies yourself.** App-level cookie store in Redis, Postgres, whatever. You read from there, build the `Cookie` header per request, and don't want the lib doing anything else. - **You want each request fully independent.** Useful for fan-out crawling where two requests on the same session shouldn't share state. - **You're debugging.** When you're trying to figure out why a response sets a weird cookie, having the jar silently swallow and replay it makes things harder. Turn it off, watch the raw headers. If none of those apply, leave the jar on. It's there for a reason. ## Code ```go package main import ( "context" "fmt" "io" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-146", httpcloak.WithoutCookieJar()) defer s.Close() ctx := context.Background() r1, _ := s.Get(ctx, "https://httpbin.org/cookies/set?demo=hello") r1.Close() r2, _ := s.Get(ctx, "https://httpbin.org/cookies") body, _ := io.ReadAll(r2.Body) r2.Close() fmt.Println(string(body)) // {"cookies": {}} (jar didn't store anything) fmt.Println(s.GetCookies()) // [] } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-146", without_cookie_jar=True) as s: s.get("https://httpbin.org/cookies/set?demo=hello") r = s.get("https://httpbin.org/cookies") print(r.json()) # {'cookies': {}} ``` ```js const httpcloak = require("httpcloak"); const s = new httpcloak.Session({ preset: "chrome-146", withoutCookieJar: true, }); try { await s.get("https://httpbin.org/cookies/set?demo=hello"); const r = await s.get("https://httpbin.org/cookies"); console.log(r.json()); // { cookies: {} } } finally { s.close(); } ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-146", withoutCookieJar: true); s.Get("https://httpbin.org/cookies/set?demo=hello"); var r = s.Get("https://httpbin.org/cookies"); Console.WriteLine(r.Text); // {"cookies": {}} ``` ## Managing cookies yourself With the jar off, you're driving. Two main patterns: 1. **Send a `Cookie` header per request.** Build the string yourself, attach it to the request headers. See [Per-Request Cookies](./per-request-cookies) for examples. 2. **Pull `Set-Cookie` out of the response.** Each `Response` exposes its raw headers; parse `Set-Cookie` yourself and stash the result wherever you want. :::info Headers still pass through Even with the jar off, the `Cookie` header you set on a request still goes out as you wrote it. The flag only suppresses the lib's auto-injection, not your manual headers. ::: ## Mixing modes You can't toggle `WithoutCookieJar` mid-session. It's a session option, set once at construction. If you need both modes (jar-on for one workflow, jar-off for another), spin up two sessions. If you need shared TLS state across the two, use `Fork()` to create a sibling that carries the same TLS resumption cache but starts fresh on cookies. Heads up: forks share the same cookie jar pointer, so if you fork from a jar-enabled parent, both share the jar. To genuinely separate the cookie state, build a fresh session. --- # Per-Request Cookies Sometimes you just want to slap a `Cookie` header onto a single request without touching the session jar. Maybe you're testing one specific cookie. Maybe you've already got the value from somewhere else. Maybe the jar is off and you're driving manually. httpcloak passes the `Cookie` header through unchanged. Whatever string you give it goes on the wire. ## Setting it on a request ```go req := &httpcloak.Request{ Method: "GET", URL: "https://httpbin.org/cookies", Headers: map[string][]string{ "Cookie": {"session=abc; lang=en"}, }, } r, _ := s.Do(ctx, req) defer r.Close() ``` ```python # Option A: explicit Cookie header r = s.get( "https://httpbin.org/cookies", headers={"Cookie": "session=abc; lang=en"}, ) # Option B: cookies kwarg (joined into a single Cookie header for you) r = s.get( "https://httpbin.org/cookies", cookies={"session": "abc", "lang": "en"}, ) ``` ```js // Option A: explicit Cookie header let r = await s.get("https://httpbin.org/cookies", { headers: { Cookie: "session=abc; lang=en" }, }); // Option B: cookies option r = await s.get("https://httpbin.org/cookies", { cookies: { session: "abc", lang: "en" }, }); ``` ```csharp // Option A: explicit Cookie header var headers = new Dictionary { { "Cookie", "session=abc; lang=en" } }; var r = s.Get("https://httpbin.org/cookies", headers: headers); // Option B: cookies parameter var cookies = new Dictionary { { "session", "abc" }, { "lang", "en" } }; var r2 = s.Get("https://httpbin.org/cookies", cookies: cookies); ``` ## How this interacts with the jar If the jar is on, the lib **merges** your per-request `Cookie` header with whatever the jar would have sent. Your header comes first, jar contents come after, joined with `; `. That's usually what you want. But if your goal is "use only this one cookie, ignore the jar," you've got two clean options: 1. Disable the jar with [`WithoutCookieJar()`](./disabling-cookie-jar) for that whole session. 2. Call `ClearCookies()` on the session right before the request, then attach your header. Don't try to fight the merge by setting `Cookie: ""`. The lib treats empty as "no per-request cookie," not "send nothing," so the jar will still inject. ## Cookie order matters for fingerprinting The order of cookies in the `Cookie` header is part of your client's fingerprint. Real browsers sort consistently (longer path first, then by creation time, per RFC 6265). httpcloak preserves whatever you give it byte-for-byte. So if you're hand-rolling cookies and you want to look like a browser, sort them yourself. Don't shuffle them across requests, don't rely on `dict` iteration order in scripts, and double-check your sort matches the order the jar would have produced. Anti-bot vendors absolutely watch for cookie order drift between requests. If the jar is doing the work for you, you don't have to think about this. The jar handles the sort. This only matters when you're driving the `Cookie` header manually. ## When per-request beats the jar A few situations where reaching for a per-request header makes more sense than touching the jar: - **One-off auth.** A single API call needs a special token cookie that shouldn't stick around for follow-ups. - **Replaying a captured session.** You've got a `Cookie` string from a browser export; just paste it. - **Cross-host scenarios.** You want the same cookie sent to two different hosts that the jar's domain rules wouldn't cover automatically. For everything else, let the jar do its job. --- # Domain and Path Matching The jar runs four checks before a cookie rides along: domain, path, secure, expiry. Get any of them wrong and the cookie either leaks where it shouldn't or silently goes missing where it should have been sent. This page covers the rules httpcloak's jar follows and the surprises that catch most people out. ## Domain matching A cookie's domain is set in one of two ways: - **No `Domain` attribute on `Set-Cookie`.** The cookie is **host-only**. It only goes back to the exact host that set it. - **`Domain=foo.example.com` (with or without leading dot).** The cookie is a **domain cookie**. It goes back to `foo.example.com` and any subdomain. Examples with a request to `https://api.example.com/`: | Stored `Domain` | Type | Sent? | |---|---|---| | (none, set by `api.example.com`) | host-only | yes | | (none, set by `example.com`) | host-only | no | | `.example.com` | domain | yes | | `example.com` | domain | yes (modern, see note) | | `.api.example.com` | domain | yes | | `.other.com` | domain | no | :::caution Leading dot, RFC vs reality Strict RFC 2109 said `Domain=example.com` (no leading dot) meant "exactly example.com." RFC 6265 and every modern browser treat it as if you wrote `.example.com`, so it includes subdomains. When httpcloak parses `Set-Cookie` from a response, it follows the modern behavior: stored with the leading dot internally, matches subdomains. Heads up: when you call the programmatic `SetCookie()` / `set_cookie()` API yourself, the same string `Domain="example.com"` (no leading dot) is currently stored as **host-only** instead, which is the older RFC 2109 reading. Asymmetry with response-header parsing. If you want a domain cookie via the API, write the leading dot explicitly: `Domain=".example.com"`. ::: You also can't set a cookie for a domain you don't control. If `api.example.com` returns `Set-Cookie: x=1; Domain=other.com`, the jar rejects it. The request host has to equal the cookie domain or be a subdomain of it. ## Path matching The path rule is a prefix match with a `/` boundary. A cookie set for `Path=/api` matches: - `/api` (exact) - `/api/` (cookie path is a prefix and the next char is `/`) - `/api/foo` - `/api/foo/bar` It does **not** match: - `/apixyz` (next char isn't `/`) - `/foo/api` (cookie path isn't a prefix) - `/` (request path is shorter) If the cookie path ends in `/`, like `Path=/api/`, the slash boundary is implicit and any request path with that exact prefix matches. If `Set-Cookie` doesn't include `Path`, httpcloak defaults it to `/`, which matches every request to that domain. ## Secure flag A cookie with `Secure` only goes out on HTTPS. Period. Even if everything else matches, a plain `http://` request won't see it. This works the other way too: a server can only **set** a `Secure` cookie over HTTPS. If `Set-Cookie: x=1; Secure` arrives over plain HTTP, the jar rejects it. ## Expiry The jar checks expiry at send time. A cookie with an `Expires` in the past gets skipped. A session cookie (no `Expires`, no `Max-Age`) lives until the session is closed or `ClearCookies()` is called. The jar doesn't sweep expired cookies eagerly. Call `ClearExpired()` if you want a clean snapshot. ## Worked example ```go s := httpcloak.NewSession("chrome-146") defer s.Close() // host-only scoping: bare domain via the API stores as host-only s.SetCookie(httpcloak.CookieInfo{ Name: "scoped", Value: "yes", Domain: "httpbin.org", Path: "/cookies", }) ctx := context.Background() r1, _ := s.Get(ctx, "https://httpbin.org/cookies") // matches → {"cookies": {"scoped": "yes"}} r1.Close() r2, _ := s.Get(ctx, "https://httpbin.org/anything") // path /anything doesn't match /cookies → cookie not sent r2.Close() ``` ```python import httpcloak with httpcloak.Session(preset="chrome-146") as s: s.set_cookie( name="scoped", value="yes", domain="httpbin.org", path="/cookies", ) r1 = s.get("https://httpbin.org/cookies") print(r1.json()) # {'cookies': {'scoped': 'yes'}} r2 = s.get("https://httpbin.org/anything") # cookie not sent, path /anything doesn't match /cookies ``` ```js const httpcloak = require("httpcloak"); const s = new httpcloak.Session({ preset: "chrome-146" }); try { s.setCookie("scoped", "yes", { domain: "httpbin.org", path: "/cookies", }); let r = await s.get("https://httpbin.org/cookies"); console.log(r.json()); // { cookies: { scoped: 'yes' } } r = await s.get("https://httpbin.org/anything"); // cookie not sent } finally { s.close(); } ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-146"); s.SetCookie("scoped", "yes", domain: "httpbin.org", path: "/cookies"); var r1 = s.Get("https://httpbin.org/cookies"); Console.WriteLine(r1.Text); // {"cookies": {"scoped": "yes"}} var r2 = s.Get("https://httpbin.org/anything"); // cookie not sent ``` ## Common gotchas - **You meant host-only but typed `Domain`.** Setting `Domain=example.com` makes the cookie ride out to every subdomain. If you only want `example.com` itself, omit `Domain` entirely. - **Path looks like it matches but doesn't.** `/api` does not match `/apiv2`. The boundary check requires a `/` (or exact equality). - **Secure cookie set over HTTP.** Some local dev setups proxy through plain HTTP. The jar won't store a `Secure` cookie if the response wasn't HTTPS. - **Setting `Domain` for someone else's domain.** A response from `a.com` setting `Domain=b.com` gets rejected. Cross-site cookie injection isn't a thing the jar will help you with. - **Forgetting expiry on long-lived sessions.** A cookie with `Max-Age=10` is gone in ten seconds. The jar will quietly stop sending it. If a server-side flow seems to log you out for no reason, check the cookie expiry first. :::warning Don't leak cookies to subdomains If you set `Domain` in your `Cookie` header without thinking, you can leak cookies to subdomains you didn't mean to hit. Match the browser's behavior: leave `Domain` empty on cookies you set yourself when you only want host-only matching. Adding the attribute opts you into subdomain delivery for the rest of the cookie's life. ::: --- # Fingerprinting Pick a preset and you're done. Or tweak the JSON, override one knob, feed in a raw JA3. Whatever the job needs. ## In this section - [What is TLS Fingerprinting](./what-is-tls-fingerprinting): a fast primer on JA3, JA4, akamai H2 hashes - [Presets](./presets): the full preset list and how to pick the right one - [JSON Preset Builder](./json-preset-builder): describe_preset, mutate JSON, load_preset_from_json. The customization path you'll actually reach for - [Custom JA3](./custom-ja3): WithCustomFingerprint with a raw JA3 string - [Akamai Shorthand](./akamai-shorthand): H2 shorthand format, change one knob without rebuilding the whole preset - [Per-Resource Priority](./per-resource-priority): RFC 7540 weights and RFC 9218 priority headers from Sec-Fetch-Dest --- # What is TLS Fingerprinting A TLS fingerprint is just the shape of your TLS handshake on the wire. The ClientHello is a packed, ordered message: cipher list, extension list, supported groups, signature algorithms, all laid out in a specific sequence. Different clients pick different orders, so their ClientHellos look different byte-for-byte. Hash the bytes and you've got a fingerprint. Anti-bot vendors keep an allowlist of known-browser hashes. Match one, you pass. Don't match, you're flagged. That's basically the whole game. The same trick applies at the H2 layer. Once the handshake's done, the connection opens with a SETTINGS frame, a WINDOW_UPDATE, sometimes PRIORITY frames, and a fixed pseudo-header order on your first request. Every browser does this a little differently, so the H2 layer hashes too. ## The fingerprint formats you'll meet ### JA3 The OG. MD5 over five comma-separated lists pulled from the ClientHello: ``` TLSVersion,CipherSuites,Extensions,EllipticCurves,PointFormats ``` Chrome 148 example: ``` 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-5-10-11-13-16-17613-18-23-27-35-43-45-51-65037-65281,29-23-24,0 ``` JA3 is basically dead. Modern Chrome shuffles its TLS extension order on every single connection, so the raw JA3 string and the `ja3_hash` change every time even though the actual browser version hasn't moved. Most defenders dropped JA3 ages ago. Don't waste energy matching it. ### JA4 The replacement everyone uses now. Compound and way more granular: ``` t13d1516h2_8daaf6152771_d8a2da3f94cd ``` Decoding: - `t13`: TLS 1.3 - `d`: TCP (`q` if you're on QUIC) - `1516`: 15 ciphers, 16 extensions - `h2`: ALPN h2 - middle hash: sorted cipher suites - last hash: sorted extensions and sig algs JA4 sorts extensions before hashing, which kills Chrome's shuffle problem. This is the one you actually want to verify against. ### Akamai HTTP/2 hash A separate fingerprint, one layer up. Hashes a tiny string with four parts: ``` SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADER_ORDER ``` Chrome 148 looks like: ``` 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p ``` That string captures the initial window size, the header table size, the connection-level window update, and the order Chrome sends `:method`, `:authority`, `:scheme`, `:path` in. Chrome and Safari ship a different pseudo-header order. Firefox lays out SETTINGS differently. All of it lands in one akamai hash, so a single value tells you a lot. ## Why default Go gets blocked `net/http` builds its ClientHello with Go's standard `crypto/tls`. Cipher list, extensions, supported curves, all bog-standard Go defaults. No real browser produces that handshake. The JA4 hash for default Go matches zero browsers, anywhere. So the bot vendor blocks by exclusion. Hash isn't on the allowlist, request is presumed bot, you eat a 403. Simple. This is also why cranking `curl --tls-cipher` to reorder ciphers won't save you. Chrome isn't just sending a different cipher list. It's sending a different extension order, a different curve list, different sig algs, different ALPN, different cert compression. The whole packet is different. Reproducing all of that end-to-end is what httpcloak exists to do. httpcloak puts ClientHello bytes on the wire that are byte-identical to a real Chrome / Firefox / Safari handshake. H2 SETTINGS, WINDOW_UPDATE, pseudo-header order all match. So does the order of regular HTTP headers Chrome sends on the first request, because Chrome being a lil bitch won't show you that order in DevTools, you can check `tls.peet.ws/api/all` for it. ## See for yourself Hit `tls.peet.ws/api/all` with the `chrome-latest` preset and look at the JA4: ```go package main import ( "context" "fmt" "io" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-latest") defer s.Close() resp, err := s.Get(context.Background(), "https://tls.peet.ws/api/all") if err != nil { panic(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-latest") as s: r = s.get("https://tls.peet.ws/api/all") print(r.text) ``` ```js const { Session } = require("httpcloak"); const s = new Session({ preset: "chrome-latest" }); const r = await s.get("https://tls.peet.ws/api/all"); console.log(r.text); s.close(); ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-latest"); var r = await s.GetAsync("https://tls.peet.ws/api/all"); Console.WriteLine(r.Text); ``` What comes back (Chrome 148, captured 2026-05): ```text ja4: t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash: 1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash: 52d84b11737d980aef856699f885ca86 ``` Those three match real Chrome 148 desktop. Run the same code through `net/http` and you'd see something like `t13d1517h2_acb858a92679_eb4d4c4c4f4f` for JA4, which matches no browser that ever shipped. Heads up: `ja3_hash` won't be stable across runs because of Chrome's extension shuffle. `ja4` and `peetprint_hash` are stable. Verify against those two. :::info `tls.peet.ws/api/all` is the workhorse. It reflects everything back: TLS, H2, headers, and the order each piece arrived in. `cf.erika.cool` and `browserleaks.com` are useful when you specifically want to see what Cloudflare's edge sees. All three are safe to test against, no C&D risk. ::: ## What's next in this section - [Presets](./presets): the bundled Chrome / Firefox / Safari profiles you can pick by name. - [JSON Preset Builder](./json-preset-builder): dump a preset to JSON, mutate it, load it back as a new preset. The customization path you'll actually use. - [Custom JA3](./custom-ja3): when you only want to override the JA3 string, this is the lightweight one. - [Akamai Shorthand](./akamai-shorthand): same idea but for the H2 fingerprint. - [Per-Resource Priority](./per-resource-priority): RFC 7540 stream weights and RFC 9218 priority headers driven by `Sec-Fetch-Dest`. --- # Presets A preset is the whole fingerprint bundle for one browser version on one platform. It packs: - TLS ClientHello (cipher list, extension list, supported groups, signature algorithms, ALPN, cert compression). - HTTP/2 SETTINGS values, WINDOW_UPDATE, pseudo-header order. - Default HTTP headers in the exact order Chrome / Firefox / Safari ships them. - RFC 7540 stream priorities and the RFC 9218 priority table per Sec-Fetch-Dest. - HTTP/3 / QUIC transport parameters (only on presets that support h3). - TCP/IP fingerprint hints (TTL, MSS, window size, for OS-level matching). Pick one by name, send a request, done. The wire bytes match the real browser. ## Picking the right preset - **Default to `chrome-latest`.** Works against the widest range of targets. Auto-tracks the newest Chrome we've shipped. - **Reach for `android-chrome-latest` if you need a mobile UA.** Mobile traffic gets scored differently on most anti-bot stacks. TLS handshake's identical to desktop Chrome, but the User-Agent and `sec-ch-ua-mobile: ?1` flag the mobile path. - **Use `ios-safari-18` (or `safari-18-ios`) if you need an iPhone fingerprint.** Different cipher list, different pseudo-header order, no RFC 7540 priorities, smaller QUIC stream window. Targets that profile iOS users will spot a Chrome preset pretending to be an iPhone in seconds. - **Pick `firefox-148` if the target only accepts Firefox.** Different cipher list, different SETTINGS layout (smaller initial window, smaller max frame size), different pseudo-header order (`m,p,a,s` vs Chrome's `m,a,s,p`). ## Available preset families ### Chrome Versions 133, 141, 143, 144, 145, 146, 147, 148. Each version has per-OS variants: | Family | Variants | |---|---| | Desktop | `chrome-148`, `chrome-148-windows`, `chrome-148-linux`, `chrome-148-macos` | | Android | `chrome-148-android` (alias: `android-chrome-148`) | | iOS | `chrome-148-ios` (alias: `ios-chrome-148`) | Bare `chrome-148` resolves to the host OS at runtime via `runtime.GOOS`. So on a Linux box, `chrome-148` gives you `chrome-148-linux`. Want the same platform UA no matter where the code runs? Use the explicit variant. ### Chrome -latest aliases Aliases that auto-track the newest shipped Chrome: ``` chrome-latest → chrome-148 chrome-latest-windows → chrome-148-windows chrome-latest-linux → chrome-148-linux chrome-latest-macos → chrome-148-macos chrome-latest-android → chrome-148-android chrome-latest-ios → chrome-148-ios ``` When Chrome 149 ships, those aliases bump in lockstep. Code on `chrome-latest` keeps rolling. Code that pinned `chrome-148-windows` stays on the same fingerprint. ### Firefox `firefox-133`, `firefox-148`, `firefox-latest`. No per-OS variants, Firefox doesn't bake enough OS info into its fingerprint for that to matter. No h3 yet either, Firefox has its own h3 quirks we haven't built out. ### Safari | Preset | Notes | |---|---| | `safari-18` (`safari-latest`) | Desktop macOS Safari 18, supports h3 | | `safari-17-ios` (`ios-safari-17`) | iPhone Safari 17, h2 only | | `safari-18-ios` (`ios-safari-18`, `safari-latest-ios`) | iPhone Safari 18, supports h3 | Safari sets `NoRFC7540Priorities=true`, so it never emits the H2 PRIORITY frame. RFC 9218 priority headers carry the signal instead. That's the single biggest tell that splits a Safari fingerprint from a Chrome one at the H2 layer, even though both ALPN as h2. ### Backwards-compat aliases The older `--` naming still works for folks on older docs: ``` ios-chrome-148 → chrome-148-ios ios-safari-18 → safari-18-ios android-chrome-148 → chrome-148-android ``` Both forms resolve to the same preset. ## Inheritance: how a new Chrome version ships in 30 seconds Each Chrome minor bump is usually pure UA + sec-ch-ua delta. TLS fingerprint, H2 SETTINGS, header order, priority table, all the same as the version before. So Chrome 148 isn't a from-scratch Go file. It's a JSON delta over Chrome 147: ```json { "version": 1, "preset": { "name": "chrome-148-windows", "based_on": "chrome-147-windows", "headers": { "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", "values": { "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"" }, "order": [ {"key": "sec-ch-ua", "value": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\""}, {"key": "sec-ch-ua-mobile", "value": "?0"}, ... ] } } } ``` That's the whole patch. TLS bytes come from chrome-147-windows (which itself inherits TLS bytes from chrome-146-windows because nothing changed in 147). H2 SETTINGS, priority table, everything else, all inherited. You can do the same. Pick a preset, dump it, change three fields, register the result. See [JSON Preset Builder](./json-preset-builder). ## Verification Hit `tls.peet.ws/api/all` with each preset and you'll see the matching JA4 / Akamai hash: ```go package main import ( "context" "fmt" "io" "github.com/sardanioss/httpcloak" ) func main() { for _, name := range []string{"chrome-latest", "android-chrome-148", "firefox-148", "safari-18-ios"} { s := httpcloak.NewSession(name) resp, _ := s.Get(context.Background(), "https://tls.peet.ws/api/all") body, _ := io.ReadAll(resp.Body) resp.Body.Close() s.Close() fmt.Println(name, string(body)) } } ``` ```python import httpcloak for name in ["chrome-latest", "android-chrome-148", "firefox-148", "safari-18-ios"]: with httpcloak.Session(preset=name) as s: r = s.get("https://tls.peet.ws/api/all") print(name, r.json()) ``` ```js const { Session } = require("httpcloak"); for (const name of ["chrome-latest", "android-chrome-148", "firefox-148", "safari-18-ios"]) { const s = new Session({ preset: name }); const r = await s.get("https://tls.peet.ws/api/all"); console.log(name, r.json()); s.close(); } ``` ```csharp using HttpCloak; foreach (var name in new[] { "chrome-latest", "android-chrome-148", "firefox-148", "safari-18-ios" }) { using var s = new Session(preset: name); var r = await s.GetAsync("https://tls.peet.ws/api/all"); Console.WriteLine($"{name} {r.Text}"); } ``` Captured fingerprints (run on 2026-05, against `tls.peet.ws/api/all`): ```text chrome-latest ja4=t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash=1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash=52d84b11737d980aef856699f885ca86 chrome-148-windows ja4=t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash=1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash=52d84b11737d980aef856699f885ca86 chrome-148-linux ja4=t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash=1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash=52d84b11737d980aef856699f885ca86 chrome-148-macos ja4=t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash=1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash=52d84b11737d980aef856699f885ca86 android-chrome-148 ja4=t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash=1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash=52d84b11737d980aef856699f885ca86 firefox-148 ja4=t13d1717h2_5b57614c22b0_3cbfd9057e0d peetprint_hash=89d89662b21018947a9a46658c4f5ede akamai_fingerprint_hash=6ea73faa8fc5aac76bded7bd238f6433 safari-18 ja4=t13d2013h2_a09f3c656075_7f0f34a4126d peetprint_hash=62b834de729e78a9f0ebd1dd099314a7 akamai_fingerprint_hash=90d8353e47699c4c38ecd773e9b5a089 safari-18-ios ja4=t13d2013h2_a09f3c656075_7f0f34a4126d peetprint_hash=62b834de729e78a9f0ebd1dd099314a7 akamai_fingerprint_hash=90d8353e47699c4c38ecd773e9b5a089 chrome-148-ios ja4=t13d2013h2_a09f3c656075_7f0f34a4126d peetprint_hash=62b834de729e78a9f0ebd1dd099314a7 akamai_fingerprint_hash=c52879e43202aeb92740be6e8c86ea96 ``` Things to spot: - Every Chrome desktop variant lands on the same JA4 / peetprint / akamai. The TLS handshake is genuinely identical across Windows / Linux / macOS Chrome. Only the User-Agent and `sec-ch-ua-platform` header tell you which OS you're on. - Android Chrome shares the same fingerprint as desktop Chrome too. Same TLS, same H2. The wire-level difference is the UA string (Mobile Safari/537.36) and `sec-ch-ua-mobile: ?1`. - Chrome on iOS shows up as Safari at the wire level, because iOS WebKit forces every browser onto the system networking stack. So `chrome-148-ios` shares its TLS handshake and JA4 hash with `safari-18-ios`. They split only on H2 SETTINGS values (chrome-148-ios advertises `2,3,4,9` vs Safari's `2,4,3,5,9`) and the User-Agent. - Firefox and Safari each get their own JA4 / peetprint / akamai. Different cipher list, different SETTINGS, different pseudo-header order. :::tip The bare `ja3_hash` field won't be stable for Chrome presets across runs. Chrome shuffles its TLS extension order on every connection, so the raw JA3 string changes and the MD5 changes with it. JA4 sorts the extension list before hashing, that's why it's stable. Always verify against `ja4` and `peetprint_hash`, never `ja3_hash`. ::: ## Full preset catalog 69 preset names total (counting -latest aliases and the old `-` naming). For the exhaustive table with version numbers, supported protocols, and platform tags, see the [Presets reference](../reference/presets). --- # JSON Preset Builder This is the customization workflow you'll actually want. Take any built-in preset, dump it as fully-resolved JSON, mutate the fields you care about, load the mutated JSON back as a new preset under a fresh name. No Go code change, no rebuild. Three function calls and you're done. ## The three functions | Function | What it does | |---|---| | `describe_preset(name)` | Returns the full preset spec as JSON. Inheritance is flattened. H2 / H3 default values are emitted explicitly. | | `load_preset_from_json(json)` | Parses + builds a preset from JSON, registers it under the name in the JSON. | | `unregister_preset(name)` | Drops a custom registration. Built-ins can't be unregistered. | ## Round-trip is byte-identical Call `describe_preset`, then `load_preset_from_json`, then `describe_preset` again, you get byte-for-byte identical JSON. We lean on that property internally, it's why our embedded Chrome 148 presets are JSON files instead of Go code. Tested for every shipped preset. What this means for you: describe, edit, load, describe, diff. The diff shows exactly what changed. No surprise drift from defaults getting dropped. ## Use cases - **Spoof a Chrome version we haven't shipped yet.** Grab `chrome-latest`, override the User-Agent and sec-ch-ua brand list, register as `chrome-149-windows`. Five minutes. - **Pin a UA OS that doesn't match your runtime.** A Linux box can ship the `chrome-148-windows` UA without touching the TLS handshake. - **Remove or add a single TLS extension.** Override `tls.signature_algorithms` or `tls.alpn` without rebuilding the whole ClientHello. - **Tweak one HTTP/2 SETTINGS value.** Bump `initial_window_size`, leave everything else alone. - **Swap in a captured ClientHello from a real browser session.** See the [Build a custom preset from a tls.peet.ws capture](/recipes/build-custom-chrome-from-tls-peet) recipe. ## Walkthrough: dump, mutate, load, send Take `chrome-148-windows`, change the User-Agent, register the result as `my-chrome-mutant`, fire a request through it. ```go package main import ( "context" "encoding/json" "fmt" "io" "github.com/sardanioss/httpcloak" "github.com/sardanioss/httpcloak/fingerprint" ) func main() { // 1. Dump chrome-148-windows as JSON. desc, err := fingerprint.Describe("chrome-148-windows") if err != nil { panic(err) } // 2. Parse it, mutate the User-Agent and the preset name. var pf fingerprint.PresetFile if err := json.Unmarshal([]byte(desc), &pf); err != nil { panic(err) } pf.Preset.Name = "my-chrome-mutant" pf.Preset.Headers.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/200.0.0.0 Safari/537.36" out, _ := json.MarshalIndent(&pf, "", " ") // 3. Load it back. This builds + registers under the new name. p, err := fingerprint.LoadAndBuildPresetFromJSON(out) if err != nil { panic(err) } fingerprint.Register(p.Name, p) // 4. Use it. s := httpcloak.NewSession("my-chrome-mutant") 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)) } ``` ```python import json import httpcloak # 1. Dump chrome-148-windows as JSON. desc = httpcloak.describe_preset("chrome-148-windows") # 2. Parse, mutate, re-serialize. pf = json.loads(desc) pf["preset"]["name"] = "my-chrome-mutant" pf["preset"]["headers"]["user_agent"] = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/200.0.0.0 Safari/537.36" ) # 3. Load back. Returns the registered name. name = httpcloak.load_preset_from_json(json.dumps(pf)) # 4. Use it. with httpcloak.Session(preset=name) as s: r = s.get("https://tls.peet.ws/api/all") print(r.text) ``` ```js const { Session, describePreset, loadPresetFromJSON } = require("httpcloak"); // 1. Dump chrome-148-windows as JSON. const desc = describePreset("chrome-148-windows"); // 2. Parse, mutate, re-serialize. const pf = JSON.parse(desc); pf.preset.name = "my-chrome-mutant"; pf.preset.headers.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/200.0.0.0 Safari/537.36"; // 3. Load back. Returns the registered name. const name = loadPresetFromJSON(JSON.stringify(pf)); // 4. Use it. const s = new Session({ preset: name }); const r = await s.get("https://tls.peet.ws/api/all"); console.log(r.text); s.close(); ``` ```csharp using System.Text.Json; using HttpCloak; // 1. Dump chrome-148-windows as JSON. var desc = CustomPresets.Describe("chrome-148-windows"); // 2. Parse, mutate, re-serialize. var doc = JsonNode.Parse(desc)!; doc["preset"]!["name"] = "my-chrome-mutant"; doc["preset"]!["headers"]!["user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/200.0.0.0 Safari/537.36"; // 3. Load back. Returns the registered name. var name = CustomPresets.LoadFromJson(doc.ToJsonString()); // 4. Use it. using var s = new Session(preset: name); var r = await s.GetAsync("https://tls.peet.ws/api/all"); Console.WriteLine(r.Text); ``` What `tls.peet.ws/api/all` reflects back: ```text user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/200.0.0.0 Safari/537.36 ja4: t13d1516h2_8daaf6152771_d8a2da3f94cd peetprint_hash: 1d4ffe9b0e34acac0bd883fa7f79d7b5 akamai_fingerprint_hash: 52d84b11737d980aef856699f885ca86 ``` The User-Agent's our custom value. The TLS / H2 fingerprint is byte-identical to the original `chrome-148-windows`. Mutation lands on exactly the field we touched, nothing else drifted. ## What `describe_preset` returns A complete `PresetFile` with everything resolved: ```json { "version": 1, "preset": { "name": "chrome-148-windows", "tls": { "client_hello": "chrome-146-windows", "psk_client_hello": "chrome-146-windows-psk", "quic_client_hello": "chrome-146-quic", "quic_psk_client_hello": "chrome-146-quic-psk" }, "http2": { "header_table_size": 65536, "enable_push": false, "max_concurrent_streams": 0, "initial_window_size": 6291456, "max_frame_size": 0, "max_header_list_size": 262144, "connection_window_update": 15663105, "stream_weight": 256, "stream_exclusive": true, "no_rfc7540_priorities": false, "settings_order": [1, 2, 4, 6], "pseudo_order": [":method", ":authority", ":scheme", ":path"], "hpack_indexing_policy": "chrome", "stream_priority_mode": "chrome", "disable_cookie_split": true, "priority_table": { "document": {"urgency": 0, "incremental": true, "emit_header": true}, "style": {"urgency": 0, "incremental": false, "emit_header": true}, "script": {"urgency": 1, "incremental": false, "emit_header": true}, "image": {"urgency": 2, "incremental": true, "emit_header": true}, ... } }, "http3": { ... }, "headers": { "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", "values": { "sec-ch-ua": "\"Chromium\";v=\"148\", ...", ... }, "order": [ {"key": "sec-ch-ua", "value": "..."}, {"key": "sec-ch-ua-mobile", "value": "?0"}, ... ] }, "tcp": { "ttl": 128, "mss": 1460, "window_size": 64240, "window_scale": 8, "df_bit": true }, "protocols": { "http3": true } } } ``` Worth noting: - Inheritance is flattened. Even though `chrome-148-windows` is internally based on `chrome-147-windows` which is based on `chrome-146-windows`, the describe output has no `based_on` field. Every value's emitted explicitly. You don't need to chase the chain. - `tls.client_hello` says `chrome-146-windows`. That's the underlying utls ClientHelloID we use. TLS bytes haven't actually changed since Chrome 146 desktop, only the User-Agent and sec-ch-ua values have. That's correct. - Every H2 SETTINGS value shows up, even the zero ones (`max_concurrent_streams: 0`, `max_frame_size: 0`). Zero means "don't emit this SETTINGS entry on the wire", and that info survives the round-trip. - The full RFC 7540 priority table lands under `http2.priority_table`. Chrome 147+ ships its real per-Sec-Fetch-Dest urgencies. Presets that opt out (Safari, iOS Chrome, iOS Safari) skip this block. For the full schema with every field documented, see the [JSON Preset Spec](../reference/json-preset-spec). ## Inheritance with `based_on` You don't have to dump and edit. You can write a thin patch JSON that lists only what you want to change, with `based_on` pointing at the parent: ```json { "version": 1, "preset": { "name": "my-chrome-mutant", "based_on": "chrome-148-windows", "headers": { "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/200.0.0.0 Safari/537.36" } } } ``` That's what our embedded `chrome-148-windows.json` does, a 28-line patch on top of `chrome-147-windows`. Inheritance is recursive with a loop guard, so cycles get caught at load time. When to use which: - `based_on` patches are tiny and readable. Prefer them for "I want N+1 of an existing browser version" cases. - Full describe, mutate, load is mandatory if you need to override a field that's normally inherited (like clearing a sec-ch-ua brand the parent set). Setting a field to its zero value in a `based_on` patch is the same as not setting it, so you have to dump and edit instead. ## Strict registration vs overwrite `load_preset_from_json` registers the preset by name and silently overwrites any existing custom registration with the same name. Built-in names are blocked, you can't shadow `chrome-latest`. Want hard collision errors instead of silent overwrites? The Go API has `RegisterStrict`: ```go p, _ := fingerprint.BuildPreset(spec) if err := fingerprint.RegisterStrict(p.Name, p); err != nil { // name already taken, bail } ``` Bindings (Python / Node / .NET) only expose the silent-overwrite path right now. :::tip This is how you support a Chrome version we haven't shipped yet. Take `chrome-latest` as the base, override the sec-ch-ua brand list and User-Agent, you've got `chrome-N+1` in five minutes. TLS handshake stays correct because Chrome rarely changes the TLS layer between minor versions, and when it does we'll ship a new preset within a release cycle. ::: --- # 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](./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. ```go 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)) } ``` ```python import httpcloak with httpcloak.Session( preset="chrome-148-windows", 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", extra_fp={ "tls_alpn": ["h2", "http/1.1"], "tls_signature_algorithms": ["ecdsa_secp256r1_sha256", "rsa_pss_rsae_sha256", "rsa_pkcs1_sha256"], "tls_cert_compression": ["brotli"], }, ) as s: r = s.get("https://tls.peet.ws/api/all") print(r.json()) ``` ```js const { Session } = require("httpcloak"); const s = new Session({ preset: "chrome-148-windows", 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", extraFp: { tls_alpn: ["h2", "http/1.1"], tls_signature_algorithms: ["ecdsa_secp256r1_sha256", "rsa_pss_rsae_sha256", "rsa_pkcs1_sha256"], tls_cert_compression: ["brotli"], }, }); const r = await s.get("https://tls.peet.ws/api/all"); console.log(r.json()); s.close(); ``` ```csharp using HttpCloak; using var s = new Session( preset: "chrome-148-windows", 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", extraFp: new Dictionary { ["tls_alpn"] = new[] { "h2", "http/1.1" }, ["tls_signature_algorithms"] = new[] { "ecdsa_secp256r1_sha256", "rsa_pss_rsae_sha256", "rsa_pkcs1_sha256" }, ["tls_cert_compression"] = new[] { "brotli" }, }); var r = await s.GetAsync("https://tls.peet.ws/api/all"); Console.WriteLine(r.Text); ``` ## Verification Send the request, read the response, look at `tls.ja3` and `tls.ja3_hash`. They mirror your input exactly: ```text 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: ```go 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) ``` ```python r = s.get( "https://tls.peet.ws/api/all", headers={ "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", }, ) ``` 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. --- # Akamai Shorthand The Akamai HTTP/2 fingerprint is a compact string that captures how a client opens an H2 connection. It's the H2 equivalent of a JA3. Anti-bot vendors hash it and check against a known-browser allowlist, same playbook as JA3. ## Format Four pipe-separated fields: ``` SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADER_ORDER ``` Real Chrome 148: ``` 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p ``` Real Firefox 148: ``` 1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s ``` Real iOS Safari 18: ``` 2:0;4:2097152;3:100;5:16384;9:1|10485760|0|m,s,p,a ``` ### SETTINGS Semicolon-separated `id:value` pairs from the SETTINGS frame. Standard H2 IDs: | ID | Name | Notes | |---|---|---| | 1 | HEADER_TABLE_SIZE | Chrome 65536, Firefox 65536, Safari omits | | 2 | ENABLE_PUSH | All browsers send 0 | | 3 | MAX_CONCURRENT_STREAMS | Safari 100, Chrome / Firefox omit | | 4 | INITIAL_WINDOW_SIZE | Chrome 6291456, Firefox 131072, Safari 2097152 | | 5 | MAX_FRAME_SIZE | Firefox 16384, Safari 16384, Chrome omits | | 6 | MAX_HEADER_LIST_SIZE | Chrome 262144, others omit | | 9 | NO_RFC7540_PRIORITIES | Safari 1, others omit | Pair order in the string matches wire-frame order. Chrome ships `1, 2, 4, 6`. Firefox: `1, 2, 4, 5`. Safari: `2, 4, 3, 5, 9`. iOS Chrome's slightly different: `2, 3, 4, 9`. Match the order or your akamai hash won't. ### WINDOW_UPDATE The connection-level WINDOW_UPDATE increment sent right after SETTINGS. Chrome 148: 15663105. Firefox 148: 12517377. Safari 18: 10485760. ### PRIORITY The H2 PRIORITY frame value. `0` means no PRIORITY frame goes out, which is what Chrome / Firefox / Safari all do as of 2026. They signal priority via the priority HTTP header instead. Older Chrome versions used to send a stream weight here. ### PSEUDO_HEADER_ORDER Comma-separated single-char identifiers for the order of pseudo-headers in the first HEADERS frame: - `m` = `:method` - `a` = `:authority` - `s` = `:scheme` - `p` = `:path` Chrome: `m,a,s,p`. Firefox: `m,p,a,s`. Safari: `m,s,p,a`. iOS Chrome: `m,s,a,p`. Every browser's different and the akamai hash captures it. ## When to override Use the akamai shorthand override when you want to keep the preset's TLS handshake but tweak the H2 fingerprint. Common cases: - A target rejects the default Chrome H2 settings but takes a slightly larger initial window. - You captured an akamai string from a real browser and want to mirror it exactly. - You're spoofing a Chrome version we haven't shipped yet that bumped a single SETTINGS value. Need anything beyond H2 SETTINGS, like overriding the priority table, the HPACK header order, or per-request priorities? Hop to the [JSON Preset Builder](./json-preset-builder). ## API `WithCustomFingerprint` accepts a JA3 and an akamai string. Set one, the other, or both. ```go package main import ( "context" "fmt" "io" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-148-windows", httpcloak.WithCustomFingerprint(httpcloak.CustomFingerprint{ // Keep Chrome's TLS, override only H2. Akamai: "1:65536;2:0;4:8388608;6:262144|15663105|0|m,a,s,p", }), ) 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)) } ``` ```python import httpcloak with httpcloak.Session( preset="chrome-148-windows", akamai="1:65536;2:0;4:8388608;6:262144|15663105|0|m,a,s,p", ) as s: r = s.get("https://tls.peet.ws/api/all") print(r.json()) ``` ```js const { Session } = require("httpcloak"); const s = new Session({ preset: "chrome-148-windows", akamai: "1:65536;2:0;4:8388608;6:262144|15663105|0|m,a,s,p", }); const r = await s.get("https://tls.peet.ws/api/all"); console.log(r.json()); s.close(); ``` ```csharp using HttpCloak; using var s = new Session( preset: "chrome-148-windows", akamai: "1:65536;2:0;4:8388608;6:262144|15663105|0|m,a,s,p"); var r = await s.GetAsync("https://tls.peet.ws/api/all"); Console.WriteLine(r.Text); ``` ## How shorthand interacts with the preset When you set `Akamai`, the parser fills in only the slots that appear in your string. Slots you skip keep the preset's value. - SETTINGS pairs you list overwrite the preset's same-ID values. - SETTINGS IDs you don't list keep the preset's value. - A non-empty `WINDOW_UPDATE` overrides, empty keeps the preset. - A non-zero `PRIORITY` weight enables the H2 PRIORITY frame, zero disables it. - A non-empty pseudo-header order overrides, empty keeps the preset's. So you can write a minimal patch and the rest of the H2 state stays correct: ``` 1::||| ``` That's a valid akamai string that says "set HEADER_TABLE_SIZE to its default, leave everything else alone". In practice you'll want three or four fields for it to be useful. ## Verifying Send a request through the override, read `tls.peet.ws`'s `http2.akamai_fingerprint` field. It matches what you sent: ```text input akamai: 1:65536;2:0;4:8388608;6:262144|15663105|0|m,a,s,p output akamai (peet): 1:65536;2:0;4:8388608;6:262144|15663105|0|m,a,s,p output akamai_hash: ``` If the reflected akamai string doesn't match exactly, the parser dropped a field. Most common cause: typo in the SETTINGS pair list. `1:65536;2:0` is fine, `1=65536,2=0` is not. The parser expects colon-separated pairs joined by semicolons. :::warning `akamai_fingerprint_hash` is an MD5 of the akamai string with sorted SETTINGS keys. Two strings that differ only in SETTINGS order produce the same hash. So `1:65536;4:6291456` and `4:6291456;1:65536` hash identically even though the strings differ. Wire-level SETTINGS frame order still matters for the H2 fingerprinters that look past the basic akamai hash, those care about the unsorted string. Always send fields in the order the real browser does. ::: --- # Per-Resource Priority Real browsers don't ask for every resource at the same priority. The HTML document is highest, the main stylesheet right behind it, deferred scripts way at the back, images somewhere in the middle. The browser signals this in two places: - **RFC 7540 stream weights** on the H2 PRIORITY frame attached to HEADERS. Numeric weight 1 to 256. - **RFC 9218 priority HTTP header** on every H2 / H3 request. Format `u=N, i` where N is urgency 0-7 and `i` is the incremental flag. Chrome 147+ desktop emits both. The header carries urgency, and the wire weight is derived from urgency by `weight = 256 - (urgency * 73) / 2`. So urgency 0 lands on weight 256, urgency 1 on 220, urgency 2 on 183, urgency 3 on 147 (Chrome's default), urgency 4 on 110. Anti-bot vendors watch this because a single-weight H2 PRIORITY frame on every request is a dead giveaway. Real Chrome traffic varies the weight per resource type. A bot client that pumps weight 256 (or weight 1) on every request looks nothing like Chrome. ## How httpcloak picks the priority The transport reads `Sec-Fetch-Dest` from the outgoing request and looks it up in a 14-destination table: | Sec-Fetch-Dest | Urgency | Incremental | Header sent | |---|---|---|---| | `document` | 0 | true | `u=0, i` | | `style` | 0 | false | `u=0` | | `script` | 1 | false | `u=1` | | `image` | 2 | true | `u=2, i` | | `font` | 1 | false | `u=1` | | `manifest` | 2 | false | `u=2` | | `audio` | 3 | true | `i` | | `video` | 3 | true | `i` | | `embed` | 0 | true | `u=0, i` | | `iframe` | 0 | true | `u=0, i` | | `empty` | 1 | true | `u=1, i` | | `object` | 0 | true | `u=0, i` | | `track` | 3 | true | `i` | | `worker` | 4 | true | `u=4, i` | Captured from real Chrome 147+ desktop traffic. Each Chrome / Firefox / Safari preset can override it via the `priority_table` field in the JSON spec. Presets that opt out entirely (Safari, iOS Chrome, iOS Safari, `no_rfc7540_priorities: true`) skip the H2 PRIORITY frame and only emit the priority header. The wire weight on the H2 HEADERS frame comes from the urgency. So `Sec-Fetch-Dest: image` lands on wire weight 183 (urgency 2), `Sec-Fetch-Dest: style` lands on wire weight 256 (urgency 0). The priority HTTP header carries the same urgency value. ## What you set, what you get Send three requests with three different `Sec-Fetch-Dest` values: ```go package main import ( "context" "io" "github.com/sardanioss/httpcloak" ) func main() { s := httpcloak.NewSession("chrome-148-windows") defer s.Close() for _, dest := range []string{"document", "style", "script", "image", "empty"} { req := &httpcloak.Request{ Method: "GET", URL: "https://tls.peet.ws/api/all", Headers: map[string][]string{ "Sec-Fetch-Dest": {dest}, "Sec-Fetch-Mode": {"no-cors"}, "Sec-Fetch-Site": {"same-origin"}, }, } resp, _ := s.Do(context.Background(), req) io.ReadAll(resp.Body) resp.Body.Close() } } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-148-windows") as s: for dest in ["document", "style", "script", "image", "empty"]: s.get( "https://tls.peet.ws/api/all", headers={ "Sec-Fetch-Dest": dest, "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Site": "same-origin", }, ) ``` ```js const { Session } = require("httpcloak"); const s = new Session({ preset: "chrome-148-windows" }); for (const dest of ["document", "style", "script", "image", "empty"]) { await s.get("https://tls.peet.ws/api/all", { headers: { "Sec-Fetch-Dest": dest, "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Site": "same-origin", }, }); } s.close(); ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-148-windows"); foreach (var dest in new[] { "document", "style", "script", "image", "empty" }) { await s.GetAsync("https://tls.peet.ws/api/all", headers: new Dictionary { ["Sec-Fetch-Dest"] = dest, ["Sec-Fetch-Mode"] = "no-cors", ["Sec-Fetch-Site"] = "same-origin", }); } ``` The `priority` HTTP header reflected back from `tls.peet.ws/api/all` (read from `http2.sent_frames[].headers`) for each value: ```text Sec-Fetch-Dest=document -> priority: u=0, i Sec-Fetch-Dest=style -> priority: u=0 Sec-Fetch-Dest=script -> priority: u=1 Sec-Fetch-Dest=image -> priority: u=2, i Sec-Fetch-Dest=empty -> priority: u=1, i ``` The H2 wire stream weight on each HEADERS frame matches: 256 for document, 256 for style, 220 for script, 183 for image, 220 for empty. Real Chrome traffic ships this exact mapping. :::info Skip `Sec-Fetch-Dest` and httpcloak's auto-detect sets it for you. Top-level navigations get `document`, XHR / fetch() requests get `empty`, sub-resource loads (image / script / stylesheet tags) keep whatever value you passed. Most sites don't actually check H2 PRIORITY weight per request, but Cloudflare and Akamai do at the H2 / H3 layer. Seeing CF challenges that don't show up in a real browser test? Priority weight mismatch is a likely culprit. ::: ## Capturing the wire-level frame The HTTP header's easy to verify, `tls.peet.ws/api/all` reflects it. The H2 PRIORITY frame on the wire takes more work. It's piggy-backed inside the HEADERS frame, not a separate frame, and `tls.peet.ws` doesn't expose it. To see the actual wire weight you'll need a Wireshark capture with the TLS keylog file, or one of the H2 fingerprinting test sites like `cf.erika.cool` that decode and reflect the priority frame. For keylog setup, see [TLS Keylog](../advanced-tls/tls-keylog). ## Overriding the priority table per preset The default 14-dest table is what every Chrome preset inherits. To override: 1. Describe the preset. 2. Edit the `http2.priority_table` block in the JSON. 3. Load the result back as a custom preset. Example: clamp every resource to urgency 1 (so all wire weights become 220 and the header is `u=1, i` for incremental, `u=1` for non-incremental): ```json { "version": 1, "preset": { "name": "chrome-148-flat-priority", "based_on": "chrome-148-windows", "http2": { "priority_table": { "document": {"urgency": 1, "incremental": true, "emit_header": true}, "style": {"urgency": 1, "incremental": false, "emit_header": true}, "script": {"urgency": 1, "incremental": false, "emit_header": true}, "image": {"urgency": 1, "incremental": true, "emit_header": true}, "font": {"urgency": 1, "incremental": false, "emit_header": true}, "manifest": {"urgency": 1, "incremental": false, "emit_header": true}, "audio": {"urgency": 1, "incremental": true, "emit_header": true}, "video": {"urgency": 1, "incremental": true, "emit_header": true}, "embed": {"urgency": 1, "incremental": true, "emit_header": true}, "iframe": {"urgency": 1, "incremental": true, "emit_header": true}, "empty": {"urgency": 1, "incremental": true, "emit_header": true}, "object": {"urgency": 1, "incremental": true, "emit_header": true}, "track": {"urgency": 1, "incremental": true, "emit_header": true}, "worker": {"urgency": 1, "incremental": true, "emit_header": true} } } } } ``` Flip `emit_header: false` on any resource where you want the priority HTTP header suppressed but the wire frame still going out. Chrome does this for async / defer scripts, the wire weight stays 147 (urgency 3) but the priority header drops. Want per-resource priority off entirely on a preset (every request gets the static `stream_weight` from H2 SETTINGS)? Set `priority_table` to an empty object `{}`. The transport falls back to the static weight. ## Per-preset behaviour | Preset family | RFC 7540 PRIORITY frame | RFC 9218 priority header | Default table | |---|---|---|---| | Chrome desktop 147+ (incl. 148) | yes | yes | 14-dest table above | | Chrome desktop 146 and below | yes (static weight 256, exclusive) | no | n/a | | Chrome Android 148 | yes | yes | 14-dest table above | | Firefox 148 | yes | yes (different urgencies, currently uses Chrome table, capture pending) | inherits Chrome table | | Safari 18 desktop | no | yes | inherits Chrome table for header values; never emits H2 PRIORITY frame | | iOS Chrome / iOS Safari | no | yes | same | Build a custom preset and you get the 14-dest table for free unless you override it. Want to opt out of RFC 7540 entirely (no PRIORITY frame on the wire)? Set `http2.no_rfc7540_priorities: true`. The priority HTTP header still fires unless you flip `emit_header: false` on every entry too. ## Why this matters A constant H2 stream weight on every request is one of the easiest H2 fingerprint giveaways. Cloudflare and Akamai both check it. The priority header check is newer, RFC 9218 only stabilized in 2022, but it's becoming standard at major edge providers. httpcloak handles both as long as your preset is a modern one (Chrome 147+, Firefox 148+, Safari 18+). Seeing edge-vendor challenges that don't reproduce in a real browser session? Capture the wire-level H2 frames from both, diff the priority weights, check if your preset's `priority_table` matches. Chrome 146 and below ship a constant `weight=256, exclusive=true` on every request. That's our oldest behaviour and modern Cloudflare flags it. For new code, stick with `chrome-latest` or any 147+. --- # Connection Lifecycle How a session lives over time. Refresh it, warm it up, switch protocols, save it for later. All here. ## In this section - [Refresh](./refresh): drop every live connection but keep tickets, like a browser tab reload - [Warmup](./warmup): multi-hop browser-style warmup before the actual request - [Protocol Switching](./protocol-switching): switch H1 / H2 / H3 mid-session - [Session Save and Restore](./session-save-restore): persist the whole session to disk, resume in another process --- # Refresh `Refresh()` drops every live connection on a session and keeps the rest of your state. Cookies stay in the jar. TLS tickets stay cached. ECH config doesn't move. The preset name and any fingerprint overrides you set are still there. Only the wires get pulled. Think of it as a browser tab reload. The next request opens fresh TCP or QUIC sockets, the TLS handshake reuses a stored ticket so it goes 0-RTT, and the cookie header on the new connection is byte-identical to the old one. The server can't easily tell a refresh from a brand-new tab on the same browser. That's the whole point. ## Why this exists Plenty of anti-bot stacks track connection age. Real browsers don't keep a TCP socket open for hours. The keep-alive timer expires, a new connection opens for the next page load. A scraper that holds one connection alive for six hours sticks out like a sore thumb. `Refresh()` lets you mimic that without throwing away your cookies or tickets. Run it on a timer. Every two or three minutes is fine. Other times you'll want it: a connection's gone stale, the server's misbehaving, or you want to switch protocols (see [Protocol Switching](./protocol-switching)). ## What survives a Refresh | State | Survives | | --- | --- | | Cookies (full jar with metadata) | Yes | | TLS 1.3 session tickets | Yes | | TLS 1.2 session IDs | Yes | | ECH config cache | Yes | | Preset name and fingerprint overrides | Yes | | Header order | Yes | | Proxy config | Yes | | Cache-validation headers (ETag / Last-Modified) | Yes | | Live TCP / QUIC connections | No, all closed | | In-flight requests | No, cancelled | | Open streaming responses | No, terminated | If you call `Refresh()` while a streaming download is mid-flight, that stream dies. There's no graceful drain. Hold onto streaming responses and finish them before refreshing. ## The 0-RTT story Tickets stay in the cache, so the next handshake after `Refresh()` resumes from the previous TLS state. On TLS 1.3 that's a 0-RTT early-data path. On TLS 1.2 it's session-ID resumption (skips the cert roundtrip but doesn't ship request bytes early). The first request after a refresh is dramatically faster than the first request on a brand-new session. :::tip Most long-running scrapers should call `Refresh()` every few minutes. Real browsers do too. A connection that's been alive for hours is one of the cheaper signals an anti-bot stack can use against you. ::: ## Code The shape's the same in every binding: send some requests, call `Refresh()`, send more. ```go s := httpcloak.NewSession("chrome-latest") defer s.Close() ctx := context.Background() // Round 1 on the original connection. for i := 0; i < 3; i++ { r, _ := s.Get(ctx, "https://tls.peet.ws/api/all") fmt.Printf("round1 #%d status=%d\n", i, r.StatusCode) r.Close() } // Cut the wire. Tickets, cookies, fingerprint state all survive. s.Refresh() // Round 2 picks up fresh sockets with TLS resumption. for i := 0; i < 3; i++ { r, _ := s.Get(ctx, "https://tls.peet.ws/api/all") fmt.Printf("round2 #%d status=%d\n", i, r.StatusCode) r.Close() } ``` ```python import httpcloak with httpcloak.Session(preset="chrome-latest") as s: # Round 1 for i in range(3): r = s.get("https://tls.peet.ws/api/all") print(f"round1 #{i} status={r.status_code}") # Cut every connection. Cookies and tickets stay. s.refresh() # Round 2 picks up clean sockets with TLS resumption. for i in range(3): r = s.get("https://tls.peet.ws/api/all") print(f"round2 #{i} status={r.status_code}") ``` ```javascript const httpcloak = require("httpcloak"); const s = new httpcloak.Session({ preset: "chrome-latest" }); try { for (let i = 0; i < 3; i++) { const r = await s.get("https://tls.peet.ws/api/all"); console.log(`round1 #${i} status=${r.statusCode}`); } s.refresh(); for (let i = 0; i < 3; i++) { const r = await s.get("https://tls.peet.ws/api/all"); console.log(`round2 #${i} status=${r.statusCode}`); } } finally { s.close(); } ``` ```csharp using HttpCloak; using var s = new Session(preset: "chrome-latest"); for (int i = 0; i < 3; i++) { var r = s.Get("https://tls.peet.ws/api/all"); Console.WriteLine($"round1 #{i} status={r.StatusCode}"); } s.Refresh(); for (int i = 0; i < 3; i++) { var r = s.Get("https://tls.peet.ws/api/all"); Console.WriteLine($"round2 #{i} status={r.StatusCode}"); } ``` ## What's NOT preserved - **Live connections.** Every TCP socket, every QUIC connection, gone. - **Active requests.** Anything in flight gets cancelled. The caller sees a context-cancelled or connection-closed error. - **Streaming responses.** Body reads fail partway. Drain or close streams before refreshing. Everything else (jar, tickets, ECH, header order, custom JA3, preset, proxy) sticks around. A save before and after `Refresh()` would only differ in the timestamp. ## When NOT to use it If you want a totally fresh session (no cookies, no tickets, nothing), don't `Refresh()`. Just close and build a new one. `Refresh()` is the "keep my identity, drop my sockets" tool. For "drop my identity" you build a new `NewSession`. Heads up: after `Refresh()` the session adds `cache-control: max-age=0` to the next request, mimicking a real browser F5. That hits servers like a deliberate cache-bust. If you don't want that signal, use a fresh session instead. `Refresh()` and `Close()` both use a timeout-bounded close path on QUIC, so a misbehaving H3 peer can't hang the call forever. --- # Warmup `Warmup(ctx, url)` runs a multi-hop preflight that mimics what a real browser does when you type a URL and hit enter. It loads the HTML, parses out the stylesheets, scripts, images and fonts the page references, then fetches them in parallel with browser-style headers and timing. By the time it returns, your session has cookies, cache validators, TLS tickets and ECH state that look like a tab someone's already used. ## Why bother Cold-start fingerprinting is real. The very first request from a session has patterns that are hard to fake. Header order's fine, JA3's fine, but the *timing* and the *request graph* are bare. There's no Referer chain. The cookie jar's empty. No cache-validation headers. No subresource fetches. The site never set an `Accept-CH` so you're not sending high-entropy client hints. None of this individually screams bot, but together it paints a "this connection just opened to grab one specific endpoint" picture, which is exactly what bots do. `Warmup` papers over a lot of that in one call. It: - Fetches the navigation HTML with proper Sec-Fetch headers. - Parses the HTML for ``, `