TLS / HTTP/2 Fingerprint Rotation
Below HTTP, TLS and HTTP/2 reveal which library is calling. The tooling that makes Python and PHP look like real browsers at the wire level.
What you’ll learn
- Understand JA3 / JA4 / Akamai HTTP/2 fingerprints.
- Use curl-cffi, tls-client, or curl-impersonate to spoof TLS.
- Decide when TLS spoofing is necessary vs overkill.
Before any HTTP byte is sent, the TLS handshake exposes a fingerprint. Cloudflare, Akamai, DataDome, and others profile the TLS ClientHello and the HTTP/2 SETTINGS frame. A scraper with perfect Chrome headers but a Python TLS library still flags as a bot, at the TLS layer.
What's in a TLS fingerprint
The ClientHello packet contains:
- TLS version range (e.g. 1.0–1.3).
- Cipher suites in client preference order.
- Extensions (server_name, supported_groups, key_share, ALPN, signature algorithms, etc.).
- Elliptic curves (supported groups).
- Point formats.
These are hashed into a JA3 string. JA4 is the newer (2023+) format that also captures TLS extension order and ALPN values.
Each library produces a distinct JA3:
- Chrome 120 on Mac: a specific hash.
- Safari 17 on iOS: a different hash.
- Python httpx + OpenSSL: a hash easily flagged as "scripting library."
- PHP curl: yet another.
HTTP/2 SETTINGS fingerprint
HTTP/2 connections begin with a SETTINGS frame whose values, order, and the subsequent HEADERS frame's HPACK compression all vary by client. Akamai uses this to fingerprint clients.
For example:
- Chrome SETTINGS: HEADER_TABLE_SIZE=65536, ENABLE_PUSH=0, INITIAL_WINDOW_SIZE=6291456, MAX_HEADER_LIST_SIZE=262144.
- curl SETTINGS: different values and order.
- Python http2: different again.
The tooling that lets you spoof
Native Python and PHP cannot easily customize the TLS stack, they use OpenSSL or NSS underneath, which have their own ClientHello structure. Special tools exist:
Python: curl-cffi
from curl_cffi import requests
session = requests.Session(impersonate="chrome120")
r = session.get("https://practice.scrapingcentral.com/challenges/antibot/tls-fingerprint")
impersonate="chrome120" makes the underlying libcurl produce Chrome 120's exact TLS handshake. Variants: chrome116, chrome119, chrome120, safari17_0, safari17_2, edge99, edge101, firefox120, etc.
Drop-in replacement for requests.Session() for most use. Adds JA3, JA4, HTTP/2 SETTINGS, and even TLS extension order.
Python: tls-client
import tls_client
session = tls_client.Session(
client_identifier="chrome_120",
random_tls_extension_order=False,
)
r = session.get(url)
Similar concept; a pure-Python (with native binary) implementation. Some teams prefer it for license reasons.
Python: playwright (just use a browser)
If you're already running Playwright for JS, the browser handles TLS naturally. No fingerprint spoofing needed; the browser IS the fingerprint.
PHP: curl-impersonate
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Use the curl-impersonate binary that ships with Chrome's TLS handshake
exec("curl_chrome120 -s '$url'", $output);
Native PHP curl uses system OpenSSL; you can't reach into the ClientHello from PHP code. The workaround: shell out to the curl-impersonate binary or proxy through a Node.js / Python service that handles the TLS layer.
For higher integration, azimov/php-curl-impersonate-cffi and similar projects exist; results vary.
PHP: Symfony Panther / Chrome via WebDriver
For PHP scrapers needing serious anti-bot, running Chrome through Panther is more reliable than wrestling with TLS at the protocol level. Pay the browser cost.
When TLS spoofing is necessary
Decision points:
| Target tier | TLS spoofing needed |
|---|---|
| Internal API, partner sites | No |
| Static catalogue, no anti-bot | No |
| Mid-tier e-commerce | Sometimes, try without first |
| Cloudflare with TLS-aware bot detection | Often yes |
| Major SERP (Google, Bing) | Yes |
| Akamai-protected (banking, airline) | Yes, plus more |
For most projects, header coherence + residential IPs is enough. TLS spoofing comes in when you've matched everything at higher layers and still get blocked.
A complete request with TLS spoof
from curl_cffi import requests
import random
UAS = [
("chrome120", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
("safari17_0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"),
]
profile, ua = random.choice(UAS)
session = requests.Session(impersonate=profile)
session.headers.update({
"User-Agent": ua,
"Accept-Language": "en-US,en;q=0.9",
})
r = session.get("https://target.com/page", proxies={"https": "http://...residential..."})
TLS, HTTP/2, headers all aligned. The result is indistinguishable from a real browser at the network layer.
What spoofing doesn't fix
TLS fingerprint spoofing handles the wire layer. It does NOT help with:
- JavaScript challenges (you need an actual JS engine, Playwright).
- CAPTCHA (separate solving, §4.38–§4.40).
- Behavioral telemetry (mouse, scroll patterns).
- Canvas/WebGL fingerprints (require GPU rendering).
For those, you still need a real or headless browser. TLS spoofing is the foundation that lets requests-style scrapers blend in for non-JS targets.
HTTP/2 considerations
When using HTTP/2, the SETTINGS fingerprint is checked. curl-cffi handles this; raw httpx with HTTP/2 enabled has its own SETTINGS signature that's distinguishable.
Some targets force HTTP/2 even for unauthenticated requests; some downgrade to HTTP/1.1 for older-looking clients. Test which path the target prefers.
Testing your fingerprint
Use a self-fingerprinting endpoint:
https://tls.peet.ws/api/all, returns your JA3, JA4, HTTP/2 fingerprint, headers.https://browserleaks.com/tls, interactive fingerprint inspector.
Hit them from your scraper. Compare your JA3 to a real Chrome's. If they match (or land on a "Chrome-like" list), your spoofing is working.
Hands-on lab
Against /challenges/antibot/tls-fingerprint:
- Hit it with default
httpx, record the response. - Hit it with
curl-cffiimpersonating Chrome 120, record the response. - Hit
https://tls.peet.ws/api/allfrom both to see the JA3 difference.
You'll see the layer change. The TLS challenge passes only when the wire-level signature matches a real browser. Header coherence alone isn't enough.
Hands-on lab
Practice this lesson on Catalog108, our first-party scraping sandbox.
Open lab target →/challenges/antibot/tls-fingerprintQuiz, check your understanding
Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.