DEV Community

Greta
Greta

Posted on

Beyond the IP: Auditing Your Own Pipeline for Cross-Account Correlation Leaks

Beyond the IP: Auditing Your Own Pipeline for Cross-Account Correlation Leaks

Ask a scraper team how they keep multiple accounts from being linked, and the answer is almost always about addresses: separate residential IPs, one identity per proxy session, careful geo-matching. That part of the industry has matured. What has not matured is the recognition that the anti-bot systems linking your accounts stopped relying primarily on IPs years ago — modern correlation engines run on the intersection of signals: request timing, header structure, navigation order, cadence regularity, error-handling behavior. Two accounts on two different IPs in two different countries that click through a site in the same order, at the same intervals, with byte-identical header sets, are the same person to any system doing this join. The IPs were never the fingerprint. Your code is.

Here's the uncomfortable corollary, and the core claim of this article: in a multi-account pipeline, correlation is not something the target does to you — it's something your architecture does to itself. Shared code, shared configuration, shared schedules, and shared retry logic all conspire to make independent identities behave identically, and the fix is to measure and break the correlation in your own traffic before someone else does. You can audit this yourself, offline, with your own request logs. Below is how.

Why identical behavior is worse than shared IPs

Think about what a correlation engine actually computes. For each identity it builds a behavioral vector: inter-request interval distribution, session start times, page traversal graph, header order and casing, TLS/HTTP2 fingerprint tuple, cookie-handling quirks, even the shape of the retry pattern when a request fails. Then it looks for pairs of identities whose vectors are suspiciously similar — and "suspiciously" here means more similar than two random real users, which is a low bar, because real humans are extremely noisy.

Your pipeline, meanwhile, is a machine for removing noise. All accounts run the same script, so their traversal graphs are identical. All accounts share a scheduler, so their intervals are drawn from the same distribution — worse, from the same deterministic schedule plus the same jitter function. All accounts share a header template, so their header sets differ only where your code randomizes them — and your randomizer has a fixed seed of structural choices (same dictionary of user agents, same accept-language ordering) that makes the variety far smaller than it looks. Retry logic is the kicker: when a request fails, every account backs off with the same exponential curve. Real users don't retry with exponential backoff; they mash refresh twice and leave. A shared failure signature across accounts is one of the strongest bot correlations there is, and almost nobody designs against it because it only appears when things are already going wrong.

The mental model I use: every account pair has a correlation surface — the set of dimensions along which they look alike. IPs cover one dimension. The audit below covers the rest.

The audit: four measurements on your own logs

The audit runs entirely on your own request logs. The precondition is that you log, per request: account ID, timestamp (millisecond resolution), URL path, status code, and the header set sent. If you don't log headers, start — you can't decorrelate what you can't see.

Measurement 1: interval distribution overlap. For each account, compute the distribution of inter-request intervals. Then compute pairwise similarity between accounts. The Kolmogorov–Smirnov statistic is the right tool — it's sensitive to shape differences, not just means:

import numpy as np
from itertools import combinations
from scipy.stats import ks_2samp

def interval_audit(logs: dict[str, list[float]]) -> list[tuple[str, str, float]]:
    """logs: account -> sorted list of request timestamps (seconds)."""
    intervals = {a: np.diff(np.sort(ts)) for a, ts in logs.items()}
    intervals = {a: d[d > 0] for a, d in intervals.items() if len(d) > 20}
    flags = []
    for a, b in combinations(intervals, 2):
        stat, p = ks_2samp(intervals[a], intervals[b])
        # High KS p-value = the two accounts pace indistinguishably.
        if p > 0.90 and stat < 0.10:
            flags.append((a, b, float(p)))
    return flags
Enter fullscreen mode Exit fullscreen mode

Read that condition carefully, because it's inverted from the usual use of KS: you're worried when the test says the distributions are the same. Two accounts whose pacing is statistically indistinguishable (p > 0.9) are correlated, full stop. A healthy multi-account fleet shows low p-values across most pairs — meaning each identity genuinely paces differently — with a handful of high-similarity pairs you then go decorrelate.

Measurement 2: traversal graph similarity. Build each account's page-visit sequence and compare the order, not just the set. The cheap version that catches most problems: for each account, extract the 3-gram sequences of URL paths (normalized to route templates), and compute Jaccard overlap of the n-gram sets across pairs. Two accounts that always visit /, /search, /item in that order share their 3-grams; two humans rarely do.

from collections import Counter

def ngrams(seq: list[str], n: int = 3) -> set:
    return {tuple(seq[i:i+n]) for i in range(len(seq) - n + 1)}

def traversal_audit(seqs: dict[str, list[str]]) -> list[tuple[str, str, float]]:
    grams = {a: ngrams(s) for a, s in seqs.items() if len(s) >= 3}
    flags = []
    for a, b in combinations(grams, 2):
        inter = len(grams[a] & grams[b])
        union = len(grams[a] | grams[b]) or 1
        overlap = inter / union
        if overlap > 0.6:
            flags.append((a, b, overlap))
    return flags
Enter fullscreen mode Exit fullscreen mode

Measurement 3: header-set entropy. Collect the header sets (order, casing, values) sent by each account and measure how much real variety your fleet presents. The failure pattern I see constantly: a USER_AGENTS list of 50 strings, but Accept-Language is hardcoded en-US,en;q=0.9 on every single request across every account. Fifty user agents paired with one language preference is not fifty personas; it's one persona wearing fifty hats, and the pairing is the giveaway because the joint distribution is what gets fingerprinted.

import math
from collections import Counter

def joint_entropy(pairs: list[tuple[str, str]]) -> float:
    c = Counter(pairs)
    total = sum(c.values())
    return -sum((v / total) * math.log2(v / total) for v in c.values())

def header_entropy_audit(headers_per_request: list[dict]) -> dict:
    ua = [h.get("User-Agent", "")[:40] for h in headers_per_request]
    lang = [h.get("Accept-Language", "") for h in headers_per_request]
    h_ua = joint_entropy(list(zip(ua, [""] * len(ua))))
    h_joint = joint_entropy(list(zip(ua, lang)))
    return {"ua_bits": h_ua, "joint_bits": h_joint,
            "leak": h_ua - h_joint}   # bits of correlation between UA and language
Enter fullscreen mode Exit fullscreen mode

The leak number is bits of dependency between your header dimensions. If user-agent carries 5 bits of entropy but the joint (UA, language) distribution carries only 5.1, your language header is collapsing almost all your persona variety. Rule of thumb: every header dimension should add real bits, and dimensions should be drawn together as coherent personas (a Chrome-on-Windows UA with a pt-BR language is a coherent persona; random independent draws produce incoherent ones that stand out a different way — build persona objects, not header salads).

Measurement 4: synchronized failure signatures. Group failed requests by timestamp bucket (say, 10-second buckets). If multiple accounts fail in the same bucket — especially with the same status code after the same number of retries — your retry logic is synchronizing your identities. This is the leak nobody audits: it's invisible in per-account dashboards and glaring in a cross-account timeline.

What to do with the flags

The audit produces a list of correlated pairs. Each pair gets decorrelated along the dimension that flagged it, and the fixes are unglamorous:

  • Pacing: give each account its own interval distribution — different means and different shapes (one account bursty with long gaps, another metronomic; draw parameters per-account at provisioning, not per-request).
  • Traversal: per-account entry points and link-following order. Randomize which navigation path reaches a target page, per account, seeded at creation so it's stable — an identity should be consistent with itself and different from its neighbors. That's the whole game: within-account consistency, between-account divergence.
  • Personas: replace independent header randomization with a persona table — coherent (UA, language, timezone, viewport) tuples, one per account, versioned like code.
  • Retry decorrelation: this one's structural. Give each account a different max-retry count, different backoff base, and a per-account retry coin — such that two accounts hitting the same site hiccup do not produce interleaved identical retry trains. And never let a fleet-wide retry storm propagate: when the site has an incident, hold the whole fleet back with jittered, decorrelated resumes, or your synchronized recovery is a confession.

Then re-run the audit weekly and treat the flag count as a real KPI, because correlation is not a one-time fix — every shared library update, every new scheduler, every "small consistency improvement" re-couples your identities. The drift is always toward uniformity, because uniformity is what your codebase optimizes for by default.

The reframe that makes this stick: an anti-bot system's job is finding the one signal your accounts share. Your job is making sure no single signal is shared by all of them. IPs are one signal. Your pacing, your paths, your personas, your failure behavior — those are four more, and they're the ones you control completely.

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)