TLS Fingerprinting Deep Dive: JA3, JA4, and Anti-Detection

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 extensions

JA3 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

ClientJA3 HashDetection
Chrome 120cd08e31494f9531f560d64c695473da9Trusted browser
Firefox 120b32309a26951912be7dba376398abc3bTrusted browser
Python requests3e0b127d4449c6e4b8e5f5e5d39e6b6dKnown scraper
Go net/httpa]0e9345684785f2f4e5e7d0e8f88e489Known scraper
curl/7.x456523fc94726331a4d5a2e1d40b2cd7Suspicious
Scrapyb5a7b68e40f3e60a3e8e49a91a3c7b25Known 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: TLS
def 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 ja4

JA4 Advantages Over JA3

FeatureJA3JA4
Hash typeMD5 (collision-prone)SHA256 (truncated)
ReadabilityOpaque hash onlyHuman-readable prefix
SortingOrder-dependentSorted (resistant to randomization)
QUIC supportNoYes (q prefix)
GranularitySingle hashThree-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, edge101

Method 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

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.


Related Reading

Scroll to Top