TLS Fingerprinting Deep Dive: JA3, JA4, and Anti-Detection
Even with perfect HTTP headers and a residential proxy IP, your TLS handshake can betray you. TLS fingerprinting analyzes the Client Hello message — cipher suites, extensions, elliptic curves, and their order — to create a unique fingerprint that identifies your HTTP client. Python’s requests library has a completely different TLS fingerprint than Chrome, and anti-bot systems know the difference.
This guide explains how TLS fingerprinting works, what JA3 and JA4 hashes are, and how to evade TLS-based detection.
How TLS Fingerprinting Works
When your client initiates a TLS connection, it sends a Client Hello message containing:
Client Hello Message:
├── TLS Version: TLS 1.3 (0x0303 with supported_versions ext)
├── Cipher Suites: [0x1301, 0x1302, 0x1303, 0xc02c, ...]
├── Extensions:
│ ├── server_name (SNI): example.com
│ ├── supported_versions: [TLS 1.3, TLS 1.2]
│ ├── signature_algorithms: [0x0403, 0x0503, ...]
│ ├── supported_groups: [0x001d, 0x0017, 0x0018, ...]
│ ├── ec_point_formats: [0x00]
│ ├── application_layer_protocol_negotiation: [h2, http/1.1]
│ ├── key_share: [x25519, secp256r1]
│ └── psk_key_exchange_modes: [psk_dhe_ke]
└── Compression Methods: [null]Different clients produce different Client Hello messages:
Chrome 120: 17 cipher suites, 16 extensions, specific order
Firefox 120: 17 cipher suites, 15 extensions, different order
Python requests: 31 cipher suites, 10 extensions
curl: 16 cipher suites, 12 extensionsJA3 Fingerprinting
JA3 (developed by Salesforce) creates an MD5 hash from five fields of the Client Hello:
JA3 = MD5(
TLSVersion,
Ciphers,
Extensions,
EllipticCurves,
EllipticCurvePointFormats
)import hashlib
def calculate_ja3(client_hello):
"""Calculate JA3 fingerprint from Client Hello data."""
# Extract components
tls_version = client_hello['version']
ciphers = '-'.join(str(c) for c in client_hello['cipher_suites'])
extensions = '-'.join(str(e) for e in client_hello['extensions'])
curves = '-'.join(str(c) for c in client_hello['elliptic_curves'])
point_formats = '-'.join(str(p) for p in client_hello['point_formats'])
# Build JA3 string
ja3_string = f"{tls_version},{ciphers},{extensions},{curves},{point_formats}"
# Calculate MD5 hash
ja3_hash = hashlib.md5(ja3_string.encode()).hexdigest()
return {
'ja3_string': ja3_string,
'ja3_hash': ja3_hash,
}
# Example: Chrome's JA3
chrome_hello = {
'version': 771, # TLS 1.2 (with 1.3 in extensions)
'cipher_suites': [4865, 4866, 4867, 49195, 49199, 49196,
49200, 52393, 52392, 49171, 49172, 156,
157, 47, 53],
'extensions': [0, 23, 65281, 10, 11, 35, 16, 5, 13, 18,
51, 45, 43, 27, 17513, 21],
'elliptic_curves': [29, 23, 24],
'point_formats': [0],
}
result = calculate_ja3(chrome_hello)
print(f"JA3 String: {result['ja3_string']}")
print(f"JA3 Hash: {result['ja3_hash']}")Known JA3 Hashes
| Client | JA3 Hash | Detection |
|---|---|---|
| Chrome 120 | cd08e31494f9531f560d64c695473da9 | Trusted browser |
| Firefox 120 | b32309a26951912be7dba376398abc3b | Trusted browser |
| Python requests | 3e0b127d4449c6e4b8e5f5e5d39e6b6d | Known scraper |
| Go net/http | a]0e9345684785f2f4e5e7d0e8f88e489 | Known scraper |
| curl/7.x | 456523fc94726331a4d5a2e1d40b2cd7 | Suspicious |
| Scrapy | b5a7b68e40f3e60a3e8e49a91a3c7b25 | Known scraper |
Anti-bot services like Cloudflare, Akamai, and DataDome maintain databases of JA3 hashes and block known scraper fingerprints.
JA4 Fingerprinting
JA4 (by FoxIO) improves on JA3 with a more structured, human-readable format:
JA4 Format: [protocol][version][SNI][cipher_count][ext_count]_
[sorted_cipher_hash]_[sorted_ext_hash]
Example: t13d1516h2_8daaf6152771_b0da82dd1658
│││ ││││││ │ │
││└──┘│││││└──┘ └── Extension hash
││ ││││└── ALPN: h2 Cipher hash
││ │││└── Extension count: 16
││ ││└── Cipher count: 15
││ │└── SNI: domain
││ └── TLS version: 1.3
│└── TCP (vs QUIC 'q')
└── Type: TLSdef calculate_ja4(client_hello):
"""Calculate JA4 fingerprint."""
# Protocol type
proto = 't' # TCP (use 'q' for QUIC)
# TLS version
version_map = {
771: '12', # TLS 1.2
772: '13', # TLS 1.3
}
version = version_map.get(client_hello['version'], '12')
# SNI present?
sni = 'd' if client_hello.get('sni') else 'i'
# Cipher and extension counts
cipher_count = f"{len(client_hello['cipher_suites']):02d}"
ext_count = f"{len(client_hello['extensions']):02d}"
# ALPN
alpn = client_hello.get('alpn', 'h1')
if 'h2' in alpn:
alpn_str = 'h2'
else:
alpn_str = 'h1'
# Sorted cipher hash (first 12 chars of SHA256)
sorted_ciphers = sorted(client_hello['cipher_suites'])
cipher_str = ','.join(str(c) for c in sorted_ciphers)
cipher_hash = hashlib.sha256(cipher_str.encode()).hexdigest()[:12]
# Sorted extension hash (first 12 chars of SHA256)
sorted_exts = sorted(client_hello['extensions'])
ext_str = ','.join(str(e) for e in sorted_exts)
ext_hash = hashlib.sha256(ext_str.encode()).hexdigest()[:12]
ja4 = f"{proto}{version}{sni}{cipher_count}{ext_count}{alpn_str}_{cipher_hash}_{ext_hash}"
return ja4JA4 Advantages Over JA3
| Feature | JA3 | JA4 |
|---|---|---|
| Hash type | MD5 (collision-prone) | SHA256 (truncated) |
| Readability | Opaque hash only | Human-readable prefix |
| Sorting | Order-dependent | Sorted (resistant to randomization) |
| QUIC support | No | Yes (q prefix) |
| Granularity | Single hash | Three-part (type_ciphers_extensions) |
Evading TLS Fingerprinting
Method 1: curl-impersonate
The most reliable way to match browser TLS fingerprints:
# pip install curl-cffi
from curl_cffi import requests
# Impersonate Chrome
response = requests.get(
"https://tls.browserleaks.com/json",
impersonate="chrome120",
proxies={"https": "http://user:pass@proxy.example.com:8080"}
)
print(response.json())
# JA3 will match real Chrome 120
# Available impersonation targets:
# chrome99, chrome100, chrome101, ..., chrome120
# firefox99, firefox100, ..., firefox120
# safari15_3, safari15_5, safari16_0, safari17_0
# edge99, edge101Method 2: tls-client (Go-based)
# pip install tls-client
import tls_client
session = tls_client.Session(
client_identifier="chrome_120",
random_tls_extension_order=True,
)
# Set proxy
session.proxies = {
"http": "http://user:pass@proxy.example.com:8080",
"https": "http://user:pass@proxy.example.com:8080",
}
response = session.get("https://tls.browserleaks.com/json")
print(response.json())Method 3: Custom OpenSSL Context (Advanced)
import ssl
import urllib3
import requests
from requests.adapters import HTTPAdapter
class TLSAdapter(HTTPAdapter):
"""Custom TLS adapter to control cipher suites and options."""
def __init__(self, ciphers=None, **kwargs):
self.ciphers = ciphers
super().__init__(**kwargs)
def init_poolmanager(self, *args, **kwargs):
ctx = ssl.create_default_context()
# Set cipher suites to match Chrome
if self.ciphers:
ctx.set_ciphers(self.ciphers)
# Set TLS options
ctx.options |= ssl.OP_NO_SSLv2
ctx.options |= ssl.OP_NO_SSLv3
ctx.options |= ssl.OP_NO_TLSv1
ctx.options |= ssl.OP_NO_TLSv1_1
# Minimum TLS 1.2
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
kwargs['ssl_context'] = ctx
super().init_poolmanager(*args, **kwargs)
# Chrome-like cipher suite order
CHROME_CIPHERS = (
"TLS_AES_128_GCM_SHA256:"
"TLS_AES_256_GCM_SHA384:"
"TLS_CHACHA20_POLY1305_SHA256:"
"ECDHE-ECDSA-AES128-GCM-SHA256:"
"ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-RSA-AES256-GCM-SHA384"
)
session = requests.Session()
session.mount("https://", TLSAdapter(ciphers=CHROME_CIPHERS))
session.proxies = {
"https": "http://user:pass@proxy.example.com:8080"
}
response = session.get("https://tls.browserleaks.com/json")Method 4: Playwright/Puppeteer (Real Browser)
The ultimate solution — use a real browser:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(
proxy={"server": "http://proxy.example.com:8080",
"username": "user", "password": "pass"}
)
page = browser.new_page()
page.goto("https://tls.browserleaks.com/json")
# TLS fingerprint is genuinely Chrome
data = page.inner_text("body")
print(data)
browser.close()Testing Your TLS Fingerprint
from curl_cffi import requests as cffi_requests
import requests as stdlib_requests
import json
def compare_tls_fingerprints(proxy_url=None):
"""Compare TLS fingerprints across different clients."""
test_url = "https://tls.browserleaks.com/json"
proxies = {"https": proxy_url} if proxy_url else None
results = {}
# Standard requests
try:
r = stdlib_requests.get(test_url, proxies=proxies, timeout=10)
results["python_requests"] = r.json()
except Exception as e:
results["python_requests"] = {"error": str(e)}
# curl-cffi (Chrome impersonation)
try:
r = cffi_requests.get(test_url, impersonate="chrome120",
proxies=proxies, timeout=10)
results["curl_cffi_chrome"] = r.json()
except Exception as e:
results["curl_cffi_chrome"] = {"error": str(e)}
# Print comparison
for client, data in results.items():
print(f"\n--- {client} ---")
if "error" in data:
print(f"Error: {data['error']}")
else:
print(f"JA3: {data.get('ja3_hash', 'N/A')}")
print(f"User-Agent: {data.get('user_agent', 'N/A')[:60]}")
print(f"TLS Version: {data.get('tls_version', 'N/A')}")
compare_tls_fingerprints("http://user:pass@proxy.example.com:8080")Internal Links
- Browser Fingerprinting: What It Is & Prevention — TLS is one component of browser fingerprinting
- How Websites Detect Bots — TLS fingerprinting in the context of bot detection
- HTTP/2 and HTTP/3 with Proxies — protocol negotiation affects your fingerprint
- Undetected ChromeDriver Tutorial — tools that handle TLS fingerprint matching
- Playwright Stealth: Anti-Detection Setup — real browser TLS fingerprints
FAQ
Can a proxy change my TLS fingerprint?
No, a standard forward proxy (using CONNECT tunnel) does not modify your TLS fingerprint. Your TLS Client Hello passes through the proxy tunnel directly to the target server. Only your HTTP client library determines the TLS fingerprint. Some specialized proxy services offer TLS fingerprint modification as a feature.
Is JA3 fingerprinting used by all anti-bot services?
Most major anti-bot services (Cloudflare, Akamai, DataDome, PerimeterX) use TLS fingerprinting as one detection signal. They typically combine it with other signals (HTTP/2 settings, header order, JavaScript challenges) for a composite bot score. TLS fingerprinting alone does not determine blocking.
How often do browser TLS fingerprints change?
Browser TLS fingerprints change with major version updates (roughly every 4-6 weeks for Chrome and Firefox). Minor updates usually do not change the fingerprint. Keep your impersonation targets updated to match current browser versions.
Can I randomize my TLS fingerprint?
Yes, but randomly generated fingerprints are actually suspicious. Anti-bot systems maintain databases of known valid fingerprints. A random fingerprint that does not match any known browser stands out more than using a consistent browser fingerprint. Always impersonate a specific, real browser.
Does TLS fingerprinting work with TLS 1.3?
Yes. While TLS 1.3 encrypts more of the handshake, the Client Hello (which contains cipher suites and extensions) is still sent in plaintext. JA3 and JA4 both work with TLS 1.3. The upcoming Encrypted Client Hello (ECH) standard will eventually encrypt the Client Hello, but adoption is still limited.
- AJAX Request Interception: Scraping API Calls Directly
- Bandwidth Optimization for Proxies: Reduce Costs & Increase Speed
- Build an Anti-Detection Test Suite: Verify Browser Stealth
- Build a Proxy Rotator in Python: Complete Tutorial
- How to Configure Proxies on iPhone and Android
- How to Use Proxies in Node.js (Axios, Fetch, Puppeteer)
- AJAX Request Interception: Scraping API Calls Directly
- Azure Functions for Serverless Web Scraping: the Complete Guide
- Build an Anti-Detection Test Suite: Verify Browser Stealth
- Build a News Crawler in Python: Step-by-Step Tutorial
- How to Configure Proxies on iPhone and Android
- How to Use Proxies in Node.js (Axios, Fetch, Puppeteer)
Related Reading
- AJAX Request Interception: Scraping API Calls Directly
- Azure Functions for Serverless Web Scraping: the Complete Guide
- Build an Anti-Detection Test Suite: Verify Browser Stealth
- Build a News Crawler in Python: Step-by-Step Tutorial
- How to Configure Proxies on iPhone and Android
- How to Use Proxies in Node.js (Axios, Fetch, Puppeteer)