Isolate More Than the IP: Storage-Layer Fingerprint Splitting for a Multi-Account Proxy Fleet
You did the responsible thing: one sticky residential exit per account, no shared ASN between concurrent sessions, fresh TLS profiles. On paper every account leaves from a different address. Then the platform links four of them anyway and bans the set in one sweep, and you assume the proxy provider "leaked." Usually it did not. The link was never the IP — it was state that survived between sessions and had nothing to do with the network at all: localStorage, IndexedDB, service-worker caches, and the browser-fingerprint surface that a shared Playwright or Chromium profile accumulates. Multi-account isolation that stops at the proxy layer is half the job.
The core point: for a multi-account fleet, the exit is only one dimension of identity. Every account needs its own isolated browser-storage context, and that context must be pinned one-to-one with its exit, because correlation is a join across any stable identifier — and unisolated client-side storage is a join key that no proxy can mask.
What actually correlates two accounts
Anti-bot and fraud systems do not need your password to link accounts. They join on any feature that is stable and rare enough. Ranked by how often they are the culprit in a proxy-fronted fleet:
-
Persistent browser storage. If two Playwright contexts share a
user-data-dir, or you reuse a browser and only swap proxies, thenlocalStorage, cookies, IndexedDB, and the cache are literally the same origin-scoped store. The platform reads a value it set in account A's storage, finds it in account B's request, and joins them instantly. The IP is irrelevant; the token inlocalStorageis the identifier. - Canvas/WebGL/audio fingerprint. A shared browser instance keeps a stable graphics/audio fingerprint. If account A and account B present byte-identical canvas hashes across different IPs, that identity is the join key.
- Service-worker scope and its cache. A registered service worker persists a cache keyed by origin; if two accounts run through the same browser, the SW state is shared and observable.
-
Timezone/locale/permissions mismatch with the exit. If your exit is in Berlin but the browser reports
en-US,America/Los_Angeles, and camera permission granted, the inconsistency is itself a signal that ties the account to a scripted fleet.
The first three are solved by storage isolation. The last is solved by making the storage context's fingerprint coherent with the exit you assigned it.
One storage context per account, bound to one exit
The primitive is a browser context with its own profile, and the discipline is never to move an account's work to a different context. In Playwright, an incognito-style context already isolates cookies/localStorage/IndexedDB from other contexts in the same browser — but only if you create a fresh context per account instead of a fresh page in a shared context.
from playwright.sync_api import sync_playwright
from dataclasses import dataclass
@dataclass
class Identity:
account: str
proxy_server: str # e.g. http://user-<sticky>:pass@host:port
timezone: str
locale: str
exit_country: str
def make_context(browser, ident: Identity):
# proxy is set PER CONTEXT -> each context truly exits elsewhere
ctx = browser.new_context(
proxy={"server": ident.proxy_server},
timezone_id=ident.timezone,
locale=ident.locale,
# a distinct storage dir per identity makes even a crash restart-safe
# (do NOT share this path across accounts)
)
ctx.add_init_script(_anti_leak_init(ident.exit_country))
return ctx
def _anti_leak_init(country: str) -> str:
# keep the reported surface consistent with the exit's geography;
# minimal, honest coherence — not a fake fingerprint arms race
tz = {"US": "America/New_York", "DE": "Europe/Berlin",
"GB": "Europe/London", "JP": "Asia/Tokyo"}.get(country)
return f"""
Object.defineProperty(navigator, 'language', {{get: () => '{country}0' }});
// (illustrative) align language with the exit country you assigned
"""
The binding rule that keeps storage isolation from leaking: the mapping account -> (context_storage_dir, exit) is fixed for the account's life. If you recreate contexts on restart, recreate them from the same storage dir and the same sticky exit, so the account's client-side identity and its network identity move together and stay mutually consistent. Swapping either one alone creates the correlation.
The isolation audit you should actually run
Do not trust that isolation happened — verify it by looking for shared join keys across the accounts you think are separate. The cheapest version is a probe that dumps every stable identifier a page can observe, run once per identity, then diffed across accounts:
PROBE_JS = """() => ({
ls: Object.keys(localStorage),
idb: window.indexedDB ? 'present' : null,
canvas: (() => {
const c = document.createElement('canvas');
const x = c.getContext('2d');
x.textBaseline = 'top'; x.font = '14px Arial';
x.fillText('probe', 2, 2);
return c.toDataURL().slice(0, 64);
})(),
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
})"""
def audit_identities(contexts):
seen = {}
collisions = []
for ident, ctx in contexts.items():
page = ctx.new_page()
page.goto("https://example.com/blank") # same origin for all
fp = page.evaluate(PROBE_JS)
key = (fp["canvas"], fp["tz"]) # rare, stable-ish identifiers
if key in seen and seen[key] != ident.account:
collisions.append((ident.account, seen[key], key))
seen[key] = ident.account
return collisions
canvas identical across two accounts means your graphics fingerprint is a shared join key regardless of proxy. A non-empty intersection of localStorage keys that the platform itself set (not your app) is a direct, undeniable link. The timezone is the coherence check: it must match the exit country you assigned, or the mismatch flags the whole fleet. Run audit_identities in CI before you ever put accounts into production; it takes seconds and it finds the "same browser, swapped proxy" bug instantly.
Why the proxy layer cannot fix this for you
It is tempting to think that because Thordata gives each session a distinct residential exit, distinctness is solved at the network layer and the rest is the platform's problem. It is not, because storage-layer identifiers never touch your exit. localStorage is replayed by the browser to the origin over the same connection, and the origin stores the association on its side. A perfect set of 40 distinct IPs carrying 40 sessions that all replay the same canvas hash and the same platform-set storage token collapses to one account with 40 addresses — which is more suspicious than one account with one address. The proxy buys you network diversity; it does not buy you per-account client-state diversity, and only isolation gives you that.
The practical checklist
One browser context (or a Playwright launch with user-data-dir unique per identity) per account — never a shared browser with swapped proxies. Bind each identity's storage to its exit and move them together or not at all. Align timezone/locale/permissions to the exit's country. Then run the collision audit as a gate, and treat any canvas or storage intersection as a red deploy.
Persistence is the part that bites on restart
The subtlety nobody expects: isolation you maintain while the fleet runs can evaporate the instant it restarts. If your orchestrator recreates contexts on boot but points several identities at the same on-disk profile (a leftover --user-data-dir, a shared Playwright storage-state JSON, or a container that mounts one volume), you have re-united accounts you spent the last run separating, and the platform sees the storage token reappear from what it considers account A's browser. Treat the storage dir as strictly one-per-account, provisioned and torn down with the identity, and assert it in the same startup path that binds the exit — if two identities ever resolve to the same storage path, fail the boot loudly instead of crawling with a shared store. That single invariant, checked at restart, is where most "it was fine until the deploy" bans actually come from.
Isolate the storage as seriously as you isolate the exit, and check that you did. The IP was never the only place your accounts could rhyme.
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)