TLS/JA3 Fingerprinting Explained for Web Scrapers

TLS/JA3 Fingerprinting Explained for Web Scrapers

TLS fingerprinting is one of the most effective methods websites use to detect automated scrapers — and one of the least understood. Unlike browser-level detection that requires JavaScript, TLS fingerprinting happens during the initial connection handshake, before any page content is delivered. This means it catches bots that have perfect headers, cookies, and JavaScript execution.

What Is TLS Fingerprinting?

Every time your client connects to an HTTPS website, it performs a TLS handshake. During this handshake, the client sends a “Client Hello” message that contains:

  • TLS version supported
  • Cipher suites the client can use (and their order)
  • Extensions supported (and their order)
  • Elliptic curves supported
  • Compression methods
  • Signature algorithms

Different HTTP clients send different Client Hello messages. Chrome’s looks different from Firefox’s, which looks different from Python’s requests library. This difference creates a unique fingerprint.

What Is JA3?

JA3 is a method for hashing TLS Client Hello parameters into a single, comparable hash. Created by Salesforce researchers, it takes five fields from the Client Hello and generates an MD5 hash:

JA3 = MD5(TLSVersion, Ciphers, Extensions, EllipticCurves, EllipticCurvePointFormats)

Example JA3 Hashes

ClientJA3 Hash
Chrome 122 (Windows)cd08e31494f9531f560d64c695473da9
Firefox 123 (Windows)b32309a26951912be7dba376398abc3b
Python requests 2.3156c42b50281e63b53b4e154f6c0e8e0f
Go net/http473cd7cb9faa642487833f5dab16ab1d
curl (default)456523fc94726331a8d59059f21d4390

Notice how each client has a unique hash. When Cloudflare or another anti-bot system sees the JA3 hash for Python requests paired with a Chrome user agent header, it knows the request is fake.

How Anti-Bot Systems Use TLS Fingerprinting

1. Client Identification

The primary use: identify what software is actually making the request, regardless of what the HTTP headers claim.

Scenario: Scraper sends User-Agent: Chrome/122
TLS fingerprint: Python requests 2.31
Result: MISMATCH → Block or challenge

2. Known Bot Signatures

Anti-bot databases maintain lists of JA3 hashes associated with known scraping tools:

  • Python requests, httpx, aiohttp
  • Go net/http, colly
  • Node.js node-fetch, axios
  • Java HttpClient, OkHttp
  • curl (default builds)

Any request with these fingerprints gets flagged immediately.

3. JA3S (Server Response Fingerprinting)

JA3S fingerprints the server’s TLS response. Anti-bot systems can use JA3S to detect when clients negotiate unusual cipher suites or TLS parameters that real browsers wouldn’t accept.

JA3 in Detail

Breaking Down a Client Hello

Client Hello:
  TLS Version: 0x0303 (TLS 1.2)
  Cipher Suites (17):
    TLS_AES_128_GCM_SHA256 (0x1301)
    TLS_AES_256_GCM_SHA384 (0x1302)
    TLS_CHACHA20_POLY1305_SHA256 (0x1303)
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
    ...
  Extensions (16):
    server_name (0x0000)
    status_request (0x0005)
    supported_groups (0x000a)
    ec_point_formats (0x000b)
    signature_algorithms (0x000d)
    ...
  Elliptic Curves (3):
    x25519 (0x001d)
    secp256r1 (0x0017)
    secp384r1 (0x0018)

The JA3 string concatenates: 771,4865-4866-4867-49195-49199-...,0-5-10-11-13-...,29-23-24,0

Then hashes it: MD5("771,4865-4866-4867-...") = cd08e31494f9531f560d64c695473da9

How Python Requests Differs from Chrome

Python’s requests library uses urllib3 with Python’s ssl module. The key differences:

  1. Cipher suite order — Python offers ciphers in a different order than Chrome
  2. Extensions — Python includes different TLS extensions
  3. GREASE values — Chrome includes random GREASE values (0x0a0a, 0x1a1a, etc.) that Python doesn’t
  4. ALPS — Chrome supports Application-Layer Protocol Settings; Python doesn’t
  5. Encrypted Client Hello — Chrome supports ECH; most Python HTTP libraries don’t

How to Spoof TLS Fingerprints

Method 1: curl_cffi (Python — Recommended)

curl_cffi uses curl-impersonate to produce browser-identical TLS fingerprints:

from curl_cffi import requests

# Impersonate Chrome's TLS fingerprint
response = requests.get(
    "https://tls.peet.ws/api/all",
    impersonate="chrome120"
)

# Check the JA3 hash
data = response.json()
print(f"JA3: {data['tls']['ja3_hash']}")
print(f"JA3 matches Chrome: True")

Supported Impersonation Targets

from curl_cffi import requests

# Available browser impersonations
targets = [
    "chrome99", "chrome100", "chrome101",
    "chrome104", "chrome107", "chrome110",
    "chrome116", "chrome119", "chrome120",
    "safari15_3", "safari15_5", "safari17_0",
    "edge99", "edge101",
]

# Session with persistent fingerprint
session = requests.Session(impersonate="chrome120")
session.proxies = {
    "http": "http://user:pass@residential.example.com:7777",
    "https": "http://user:pass@residential.example.com:7777"
}

response = session.get("https://target-site.com")

Method 2: tls-client (Python)

import tls_client

session = tls_client.Session(
    client_identifier="chrome_120",
    random_tls_extension_order=True
)

response = session.get("https://target-site.com", headers={
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
    )
})

Method 3: Real Browser (Most Reliable)

Using a real browser through automation produces authentic TLS fingerprints:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    # Chromium produces Chrome's real TLS fingerprint
    browser = p.chromium.launch()
    page = browser.new_page()

    page.goto("https://tls.peet.ws/api/all")
    # TLS fingerprint will match real Chrome
    browser.close()

Method 4: curl-impersonate (Command Line)

# curl-impersonate compiles curl with browser-specific TLS settings
curl-impersonate-chrome https://tls.peet.ws/api/all

# Or with specific version
curl_chrome120 https://target-site.com

JA4 and Beyond

JA3 has known limitations (hash collisions, inability to distinguish minor version differences). Newer fingerprinting methods are emerging:

JA4 Fingerprint

JA4 improves on JA3 by:

  • Separating TLS version, cipher count, and extension count into readable components
  • Including ALPN and SNI information
  • Using a more collision-resistant hash structure
JA4 format: t13d1516h2_8daaf6152771_e5627efa2ab1
             |   |    |   |              |
             |   |    |   |              Extension hash
             |   |    |   Cipher hash
             |   |    ALPN
             |   Cipher count + Extension count
             TLS version

HTTP/2 Fingerprinting

Beyond TLS, HTTP/2 settings (SETTINGS frame, WINDOW_UPDATE, PRIORITY frames) also vary between clients and create additional fingerprinting vectors.

Chrome HTTP/2 SETTINGS:
  HEADER_TABLE_SIZE: 65536
  MAX_CONCURRENT_STREAMS: 1000
  INITIAL_WINDOW_SIZE: 6291456
  MAX_HEADER_LIST_SIZE: 262144

Python httpx HTTP/2 SETTINGS:
  HEADER_TABLE_SIZE: 4096
  MAX_CONCURRENT_STREAMS: 100
  INITIAL_WINDOW_SIZE: 65535

Testing Your TLS Fingerprint

from curl_cffi import requests

# Check your fingerprint against known browser fingerprints
session = requests.Session(impersonate="chrome120")

resp = session.get("https://tls.peet.ws/api/all")
data = resp.json()

print(f"TLS Version: {data['tls']['version']}")
print(f"JA3 Hash: {data['tls']['ja3_hash']}")
print(f"JA3 String: {data['tls']['ja3'][:80]}...")
print(f"Cipher Suites: {len(data['tls']['ciphers'])} suites")
print(f"Extensions: {len(data['tls']['extensions'])} extensions")

Other testing endpoints:

  • https://tls.peet.ws/api/all — Detailed TLS and HTTP/2 fingerprint
  • https://tls.browserleaks.com/json — TLS fingerprint analysis
  • https://ja3er.com/json — JA3 hash lookup

Common Mistakes

  1. Mismatched UA and TLS — Sending Chrome user agent with Python TLS fingerprint
  2. Ignoring HTTP/2 — Matching TLS but sending HTTP/1.1 (Chrome defaults to HTTP/2)
  3. Static fingerprints — Using the same TLS fingerprint across millions of requests
  4. Old impersonation targets — Using Chrome 99 impersonation when the site checks for recent versions
  5. Missing GREASE — Chrome includes randomized GREASE values; static tools don’t

FAQ

Can I change my JA3 fingerprint without curl_cffi?

Standard Python libraries (requests, httpx, aiohttp) don’t allow fine-grained TLS configuration. You’d need to modify the underlying OpenSSL/BoringSSL settings, which is impractical. Use curl_cffi or tls-client — that’s exactly what they’re designed for.

Does JA3 fingerprinting work through proxies?

Yes. TLS fingerprinting happens between the client and the first TLS endpoint. When using an HTTPS proxy, the target site sees the proxy’s TLS fingerprint for the proxy connection, but your fingerprint for the target connection (via CONNECT tunnel). When using a SOCKS proxy or HTTP proxy with CONNECT, the target site sees your original TLS fingerprint.

How often do browser TLS fingerprints change?

Browser TLS fingerprints change with major version updates (roughly every 4-6 weeks for Chrome). When impersonating a browser, use a recent version target and update regularly. Sending a Chrome 99 TLS fingerprint in 2026 is suspicious.

Is JA3 fingerprinting enough to block all bots?

No. JA3 is one layer. Sophisticated scrapers using curl_cffi or real browsers produce valid JA3 hashes. Anti-bot systems combine JA3 with HTTP/2 fingerprinting, header analysis, browser fingerprinting, and behavioral signals for comprehensive detection.

Conclusion

TLS/JA3 fingerprinting is the first line of defense against web scrapers, catching requests before any page content is delivered. For Python scrapers, curl_cffi with browser impersonation is the simplest and most effective solution. Always ensure your TLS fingerprint matches your user agent, and combine TLS spoofing with residential proxies for maximum effectiveness.

Useful Resources


Related Reading

Related: New to the concept? Start with our TLS fingerprint explained.

Scroll to Top