Rotate More Than the IP: Matching TLS Fingerprint Variety to Your Proxy Exits
Most teams that rotate proxies rotate exactly one thing: the IP address. The TLS ClientHello that leaves their scraper is byte-for-byte identical on request 1 and request 100,000. That's a problem, because modern anti-bot systems don't score signals in isolation — they join them. A JA3 hash is a weak signal alone. A JA3 hash shared across hundreds of unrelated residential IPs in the same week is a strong one.
This post is about the join, and about a rotation strategy I've come to rely on: pair fingerprint rotation with exit rotation, and keep them sticky to each other. Your browser persona should hold an IP and a fingerprint together, live in them for a while, and then rotate both at the same time.
The correlation anti-bot systems actually run
Imagine the view from a bot-detection vendor. They see traffic from millions of clients. For each connection they can cheaply log:
- the source IP (and its ASN, geo, reputation),
- the TLS fingerprint (JA3/JA4),
- the HTTP/2 fingerprint,
- the header order and values.
Individually, each is noisy. Chrome has a handful of dominant JA3 hashes in the wild at any time, so "JA3 = Chrome 124" proves nothing. But now consider this pattern: 400 different residential IPs, spread across 30 ASNs and 12 cities, all presenting the exact same JA3 and the exact same HTTP/2 SETTINGS frame and the same header ordering, all hitting the same endpoint cluster, all within a 24-hour window. No organic population looks like that. Real Chrome users disagree with each other constantly — different versions, different platforms, different extension-induced header quirks.
The mistake is thinking of a fingerprint as an identity. It's closer to a correlation key. When you rotate the IP but not the fingerprint, you're handing the detector the join key for free.
There's a second, subtler failure mode: rotating the fingerprint too aggressively on a single exit. If one IP presents Chrome-on-Windows at 10:00 and Safari-on-macOS at 10:04, that IP is now flagged as a proxy or a compromised host by any vendor doing per-IP fingerprint history. Exits get burned — and if you're renting sticky sessions, you're burning your own money.
So the constraint set is:
- Across the pool, fingerprints should be diverse — matching the real-world browser mix of your target geo.
- Within one exit's lifetime, the fingerprint should be stable — a real user doesn't change browsers every four minutes.
- The fingerprint and the exit should rotate together.
A persona-to-exit affinity manager
I implement this as a small layer that sits between your task queue and your proxy client. It maintains "personas" — each persona is a (TLS profile, HTTP profile, header profile) tuple — and a sticky mapping from persona to exit. When the exit rotates, the persona rotates with it.
For the TLS side in Python, curl_cffi is the practical choice: it impersonates real browser TLS stacks (BoringSSL cipher ordering and extensions included), which requests/urllib3 simply cannot do.
# pip install curl_cffi
import time
import random
from dataclasses import dataclass, field
from curl_cffi import requests as cffi_requests
@dataclass
class Persona:
name: str # e.g. "chrome_win_124"
impersonate: str # curl_cffi impersonate target
exit_id: str = "" # proxy session/exit this persona is bound to
born_at: float = 0.0
class PersonaPool:
"""
Binds (TLS persona) <-> (proxy exit) for the lifetime of a sticky window.
Rotates both together. Keeps pool-wide fingerprint mix diverse.
"""
def __init__(self, exits, ttl_seconds=1200, seed=42):
self.exits = list(exits) # e.g. Thordata session IDs
self.ttl = ttl_seconds
self.rng = random.Random(seed)
# Weighted to match the browser mix you actually expect in the
# target geography — don't ship uniform weights to a US retail
# endpoint and claim it's realistic.
self.persona_profiles = [
("chrome_win_124", "chrome124", 0.55),
("chrome_mac_123", "chrome123", 0.20),
("safari_mac_17", "safari17_0", 0.15),
("edge_win_124", "edge101", 0.10),
]
self._bindings = {} # exit_id -> Persona
def _new_persona(self) -> Persona:
names, targets, weights = zip(*self.persona_profiles)
name, target = self.rng.choices(
list(zip(names, targets)), weights=weights, k=1
)[0]
return Persona(name=name, impersonate=target)
def persona_for_exit(self, exit_id: str) -> Persona:
now = time.time()
p = self._bindings.get(exit_id)
# Rotate persona when the exit itself is new, or the pair has
# lived past its TTL. Never rotate one without the other.
if p is None or (now - p.born_at) > self.ttl:
p = self._new_persona()
p.exit_id = exit_id
p.born_at = now
self._bindings[exit_id] = p
return p
def fetch(url, exit_id, proxy_template, pool):
p = pool.persona_for_exit(exit_id)
proxies = {"https": proxy_template.format(session=exit_id),
"http": proxy_template.format(session=exit_id)}
r = cffi_requests.get(url, impersonate=p.impersonate,
proxies=proxies, timeout=30)
return r, p
if __name__ == "__main__":
# With a residential provider that supports session IDs, each session
# string pins a distinct exit IP for its sticky window.
template = ("http://user-session-{session}:pass@"
"gw.example-thordata-proxy.net:8080")
pool = PersonaPool(exits=[f"s{i:04d}" for i in range(8)])
for exit_id in pool.exits:
r, persona = fetch("https://tls.peet.ws/api/all", exit_id,
template, pool)
ja3 = r.json().get("tls", {}).get("ja3", "?")
print(f"{exit_id}: persona={persona.name:16s} ja3={ja3[:16]}...")
Run this and you should see two things: within one process lifetime, each exit keeps a stable persona (try fetching twice in a row — the JA3 must not change), and across exits the JA3 values differ. Both properties are what you're paying for.
Verify it, don't assume it
The verification step is where most implementations quietly rot. Two checks belong in your pipeline, not in a one-off script:
Check 1 — stability per exit. Poll a fingerprint echo endpoint (e.g. tls.peet.ws/api/all, which returns JA3/JA4 and the HTTP/2 fingerprint) through the same sticky exit several times over its window. Any change means something between you and the target is rewriting your ClientHello — some filtering gateways do exactly that — and your impersonation is not surviving the route. You need to know that per exit tier, not per client build.
Check 2 — distribution across the pool. Log (exit_id, ja3) for a few thousand requests and look at the pool-wide distribution. If 100% of your traffic carries one fingerprint, your rotation layer is broken or decorative. If any single exit flips fingerprints mid-window, your affinity logic has a race — usually two workers grabbing the same exit through different pool instances. Make the pool a singleton, or move the binding into Redis.
from collections import Counter
def audit_fingerprints(samples):
"""samples: list of (exit_id, ja3_hash) tuples from production logs."""
per_exit = {}
for exit_id, ja3 in samples:
per_exit.setdefault(exit_id, []).append(ja3)
unstable = [e for e, js in per_exit.items() if len(set(js)) > 1]
dist = Counter(ja3 for _, ja3 in samples)
top_share = dist.most_common(1)[0][1] / len(samples)
print(f"exits observed: {len(per_exit)}")
print(f"unstable exits: {len(unstable)} (must be 0)")
print(f"distinct fingerprints: {len(dist)}")
print(f"top fingerprint share: {top_share:.1%} "
f"(aim for < 40% on a 4-persona mix)")
return len(unstable) == 0 and top_share < 0.5
For a four-persona weighted mix like the one above, the top fingerprint should sit around 55% of traffic if the weights hold — the < 40% threshold deliberately fails if your weights collapsed to one profile. Tune the assertion to your own persona set; the point is to have one.
The parts I got wrong before this worked
Three practical notes from running this in production:
Weight your personas by geography. A German news site's real audience has a Safari share that a US electronics retailer's doesn't. Pull the browser stats for your target market and mirror them. Uniform weighting is itself a fingerprint.
Version drift will burn you quarterly. When Chrome ships a new major and your chrome124 persona becomes a minority of real traffic, your mix silently becomes anomalous. Put persona versions in config and refresh them on a schedule — this is maintenance, not a one-time setup.
HTTP-level coherence is part of the same binding. The persona object is the right place to hang header order, Accept-Language, and even the TLS-extension-level settings that curl_cffi targets control. A Chrome TLS stack sending Firefox-style Accept headers defeats the whole exercise. In our stack, one persona object owns every layer of the client presentation, and the exit is just another attribute of it.
The mental model that made this click for me: an exit IP without a stable persona is a stranger the site has never met; a persona without a fresh IP is a regular the site has watched too long. You need both, and you need them bound together for exactly as long as a plausible user session — no longer.
Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele
Top comments (0)