TLS Impersonation Libraries: curl_cffi, utls, wreq
TLS impersonation libraries compared — curl_cffi, wreq, utls, CycleTLS, curl-impersonate: which layers each replays, where they break, which to pick.
On this page
TLS impersonation libraries replay a real browser’s first few hundred bytes — ClientHello, HTTP/2 SETTINGS, header order — so the wire shape your script emits matches a fingerprint the detection vendor already trusts from millions of real users. TLS impersonation is necessary, not sufficient. The same lib that gives you the right JA4 will still get IP-rep-blocked at a major CDN edge — here’s the cheap test to run first, then the library matrix.
Quick Reference: capability matrix
State verified live 2026-05-20: GitHub release tags + default-branch pushed_at. The single biggest correction versus older write-ups: the original lwthiker/curl-impersonate is unmaintained, and the active project is lexiforest/curl-impersonate. Anyone landing on the 6k-star lwthiker repo first picks a 2024-frozen toolchain.
| Library | Lang | JA3 | JA4 | HTTP/2 fp | Header order | Real BoringSSL/NSS | Maint | Last release |
|---|---|---|---|---|---|---|---|---|
| lexiforest/curl_cffi | Python (cffi → libcurl-impersonate) | ✅ | ✅ | ✅ | ✅ | ✅ | active | v0.15.1b1 (2026-04-23) |
| lexiforest/curl-impersonate | C / curl fork (active) | ✅ | ✅ | ✅ | ✅ | ✅ | active | v1.5.6 (2026-05-02); v2.0.0a1 prerelease 2026-05-15 in progress |
| lwthiker/curl-impersonate | C / curl fork (original) | ✅ | ✅ | ✅ | ✅ | ✅ | unmaintained since 2024 | v0.6.1 (2024-03-02) |
| 0x676e67/wreq | Rust | ✅ | ✅ | ✅ | ✅ | ✅ (BoringSSL fork) | active | v6.0.0-rc.28 (2026-02-11) |
0x676e67/wreq-python (formerly rnet) | Python (PyO3 → wreq) | ✅ | ✅ | ✅ | ✅ | ✅ | active | tracks wreq |
| refraction-networking/utls | Go (crypto/tls drop-in) | ✅ | partial (TLS only) | manual (your x/net/http2) | manual | Go crypto/tls fork that mimics browser ClientHellos (Chrome, Firefox, Edge, Safari) | active | v1.8.2 (2026-01-13) |
| Danny-Dasilva/CycleTLS | Node + Go subprocess | ✅ | partial (preset-dependent) | weak | partial | uses utls | partial maint | cycletls/v2.0.3 |
| FoxIO-LLC/ja4 | spec + Rust/Go/Python tooling | n/a | ✅ (defines JA4) | ✅ (JA4H/S/T family) | n/a | n/a | active | v0.18.8 (2025-11-19) |
| salesforce/ja3 | spec + Python | ✅ | — | — | — | n/a | archived 2025-05 | — |
Two follow-ons that bite if you skim:
https://github.com/0x676e67/rnet301-redirects towreq-python. The project was renamed — update bookmarks and any internal docs that still say “rnet”.ja4db.comwas unresponsive from at least one VPS region on 2026-05-20. The canonical JA4 DB is mirrored insideFoxIO-LLC/ja4and the Wireshark plugins; do not putja4db.comin a CI lookup path.
Read down the row for the library, across for the capability gaps. The JA4 / HTTP/2 / header-order trio is the gate; “Real BoringSSL/NSS” is why a row has ticks in the first three; “maint” decides whether the row will still be honest in six months.
The cheap triage before reaching for a library
Before you compare libraries, confirm you’re actually being blocked by TLS — not by IP reputation, ASN classification, or a header coherence rule that no impersonation library can fix. The operating lesson from running this in production: same 403 + same body before and after impersonation → IP rep, not TLS. Swapping libraries does nothing.
The probe that anchors this is one function, hitting tls.peet.ws to see the JA4 your client actually emits:
def verify_cffi_ja4(impersonate: str = "chrome131") -> str: """Hit tls.peet.ws and return the observed JA4. Use to confirm the impersonation actually applied before running the experiment.""" from curl_cffi import requests as cffi_req r = cffi_req.get("https://tls.peet.ws/api/all", impersonate=impersonate, timeout=15) return r.json().get("tls", {}).get("ja4", "")
# Real Chrome 131 JA4 starts with t13d1516h2_ (shared across modern# Chromium-family browsers: Chrome 110+, recent Edge, Brave — see# https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md)ja4 = verify_cffi_ja4("chrome131")assert ja4.startswith("t13d1516h2_"), f"impersonation did not apply: {ja4}"The decoded prefix (t13d1516h2) is covered in the JA4 t13d1516h2 snippet; the JA3-vs-JA4 background is in JA3 vs JA4. The same tls.peet.ws response also carries the Akamai HTTP/2 fingerprint hash under http2.akamai_fingerprint_hash, which is the cheapest way to confirm your HTTP/2 SETTINGS + pseudo-header order replay matches the impersonated browser without standing up your own H2 dissector. Once that probe asserts cleanly, hit the target both with stock requests and with curl_cffi impersonate="chrome131". Two outcomes:
- 403 changes to 200: the gate was TLS/HTTP-2 fingerprinting. The library matrix below is now relevant.
- 403 unchanged (often identical body, identical error code): the gate is upstream of TLS. Likely IP rep, ASN, or per-customer ML scoring of the egress. Stop. No library swap fixes this; you need a different egress or a different access pattern.
This triage takes thirty seconds and removes about half the “which TLS library should I use” questions before they happen.
When each library wins
| Use case | Pick | Why |
|---|---|---|
| Python scraper, need to match Chrome/Firefox/Safari JA3+JA4+HTTP/2 today | lexiforest/curl_cffi | One-liner impersonate="chrome131"; presets re-baked each Chrome stable; the simplest baseline for Python experiments. |
| You need the C binary (shell, Tauri sidecar, polyglot env) | lexiforest/curl-impersonate | Active fork of the dead lwthiker tree; ships prebuilt curl_chrome131 etc. New Chrome targets land here. |
| Rust service, native-typed TLS + HTTP/2 without shelling out | 0x676e67/wreq | Pure Rust on a BoringSSL fork, reqwest-shaped builder. HTTP/2 SETTINGS / WINDOW_UPDATE / pseudo-header order are first-class config, not patched overrides on a C stack. wreq-python for the PyO3 binding. |
Go service wants Firefox/Chrome JA3 in an existing http.Transport chain | refraction-networking/utls | Drop-in tls.Client replacement; integrates with golang.org/x/net/http2. Caveat: utls only ships TLS — HTTP/2 fingerprint fidelity is your problem. It’s a Go crypto/tls fork that reproduces specific browser ClientHellos (Chrome, Firefox, Edge, Safari) in pure Go, not a link against real BoringSSL/NSS. |
| You’re stuck on Node and CycleTLS works for the target | CycleTLS | Path of least resistance from Node. Verify HTTP/2 fidelity against the release you pulled; presets drift. Otherwise prefer subprocessing curl_cffi. |
| You’re measuring a fingerprint, not producing one | FoxIO-LLC/ja4 + tls.peet.ws | The JA4 spec + reference impls; pair with the verify_cffi_ja4 probe above. |
| You inherited salesforce/ja3 | Migrate | Repo archived 2025-05. JA4 is the modern superset; the JA3 hash is unstable post Chrome 110 anyway. |
Two structural points the table doesn’t show:
curl_cffi is not a parallel implementation of curl-impersonate — it’s CFFI bindings to the same C library. Everything curl-impersonate patches at TLS / HTTP-2 / header layers, curl_cffi inherits by construction. Choose between them on language ergonomics, not capability.
wreq matters because it’s the first non-libcurl impersonation client where HTTP/2 fingerprint config is a builder field, not a downstream patch on someone else’s HTTP/2 stack. For high-concurrency Rust services that’s the difference between one task per request on tokio and libcurl’s multi-handle.
What none of them solve
A perfect TLS + HTTP/2 + header replay only gets you past layers 1–3 of the detection hierarchy. Layer 0 — the TCP/IP fingerprint, including JA4T — is owned by the kernel of whatever host (or proxy) your packets leave from. No impersonation library rewrites TCP options, window scale, or initial TTL. If your proxy is Linux and your User-Agent claims Windows, that’s a contradiction the library cannot fix.
Layers 5–7 — cross-header consistency, IP reputation, behavioural analysis — sit outside library scope too. The library will happily send sec-ch-ua-platform: "Windows" alongside a Mac OS X User-Agent; making them agree is your job. It does not pick the egress ASN, so datacenter classification is your problem. It cannot pace requests, vary access patterns, or fake mouse movement. CAPTCHAs and Turnstile require a browser to render and are irrelevant to HTTP-only clients in either direction (you can’t pass them, but they also can’t fire). Per-customer ML models (Cloudflare 2025) train on each site’s specific baseline, so “looking like Chrome globally” is no longer enough — you have to look like the kind of Chrome that visits that site. See bot-detection-2026 for what owns the rest of the stack.
Sources
- lexiforest/curl-impersonate — actively maintained fork (v1.5.6, 2026-05-02).
- lexiforest/curl_cffi — Python binding over libcurl-impersonate.
- lwthiker/curl-impersonate — original project, unmaintained since 2024.
- 0x676e67/wreq — Rust impersonation client on a vendored BoringSSL fork.
- 0x676e67/wreq-python — PyO3 binding; the project formerly known as
rnet. - refraction-networking/utls — Go TLS library with ClientHello control (v1.8.2, 2026-01-13).
- Danny-Dasilva/CycleTLS — Node + Go impersonation bridge.
- FoxIO-LLC/ja4 — JA4 specification and reference implementation.
- salesforce/ja3 — archived 2025-05; historical reference only.
- RFC 8446 §4.1.2 — TLS 1.3 ClientHello structure.
- RFC 9113 — HTTP/2, including SETTINGS (§6.5) and pseudo-header rules (§8.3).