Every maintainer of an authenticated scraping fleet knows the ticket: "Account works fine locally, works fine from the office, works fine through the sticky test exit — but in production it gets kicked to the login page every few minutes." The instinctive response is to blame the cookies, re-login harder, add retry loops. That is exactly backwards for one specific and very common failure class.
The claim I want to make precise: there is a distinct failure mode where authentication state is valid, cookies are fresh, and the request is well-formed — and the session dies anyway, specifically because the exit IP changed mid-session. You cannot fix it in the cookie layer. You can only see it if you deliberately test for it, and the test is a controlled A/B between pinned and rotating exits. Building that differential diagnosis into your pipeline turns a class of "unexplained" fleet failures into a five-minute diagnosis.
Why IP churn kills sessions that are otherwise perfect
Session-IP binding comes in more flavors than most teams realize, and they fail differently:
- Hard binding — the server stores the IP (or IP /24) observed at login and invalidates the session token the moment it sees the cookie arrive from a different range. Symptom: an immediate redirect to login, 100% of the time, the first request after an exit change.
- Soft scoring — the server accumulates "cookie seen from N distinct ASNs in M minutes" as a risk feature. Symptom: no logout at first; then a CAPTCHA gate on random routes, then throttling, then eventually the session is invalidated. Stochastic and delayed, which makes it look like "the site is being moody today."
- Gradient binding — some platforms bind at login only for new devices (identified by fingerprint), so the same account with a stable browser fingerprint tolerates IP changes, but a headless client whose fingerprint regenerates per run gets hard-bound every time. Symptom: "it worked when we tested it manually."
The common thread: the failure is triggered by the transition, not by either endpoint. A session that starts and ends on one exit is fine; the same session crossing exits mid-flight is not. This is why single-exit manual tests can never reproduce it, and why the production fleet — where sticky windows expire and rotation happens under load — sees it constantly.
The differential diagnosis harness
The fix is to stop treating "is this session valid?" as a boolean you check with one request, and instead run a paired probe: the same authenticated request, fired back-to-back, once through the pinned exit that issued the session, once through a deliberately fresh rotating exit. Four outcomes, four diagnoses:
| Pinned exit | Rotating exit | Diagnosis |
|---|---|---|
| OK | OK | Session is IP-agnostic; your logouts have another cause (cookie expiry, inactivity timeout) |
| OK | Login redirect | Session-IP binding confirmed; your sticky discipline is the bug |
| Login redirect | Login redirect | Cookie is dead; re-auth is genuinely required |
| Login redirect | OK | Token refresh raced or you probed a stale exit; investigate ordering, not IP |
Here is a compact, runnable harness built on httpx with an explicit per-request proxy (never env vars — environment-level proxy config leaks into exactly the background requests that ruin this kind of experiment):
import httpx
class AuthDifferentialProbe:
"""Paired-packet diagnosis of session-IP binding.
pinned_proxy: the exact exit the session was created on,
expressed as the provider's sticky-session URL.
rotating_proxy: an unpinned/rotating entry point of the same pool.
"""
LOGIN_REDIRECT_HINTS = ("/login", "/signin", "/accounts/login",
"/auth", "/session/new")
def __init__(self, pinned_proxy: str, rotating_proxy: str,
timeout: float = 20.0):
self.clients = {
"pinned": httpx.Client(proxy=pinned_proxy, timeout=timeout,
follow_redirects=False),
"rotating": httpx.Client(proxy=rotating_proxy, timeout=timeout,
follow_redirects=False),
}
def _verdict(self, resp: httpx.Response) -> str:
if resp.status_code in (401, 403):
return "DENIED"
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("location", "").lower()
if any(h in loc for h in self.LOGIN_REDIRECT_HINTS):
return "LOGIN_REDIRECT"
return "OTHER_REDIRECT"
if resp.status_code == 200:
# some sites serve 200 with a client-side bounce; cheap heuristic
body_head = resp.text[:4000].lower()
if 'name="password"' in body_head or "sign in to continue" in body_head:
return "SOFT_LOGIN_PAGE"
return "OK"
return f"HTTP_{resp.status_code}"
def diagnose(self, url: str, cookies: dict) -> dict:
results = {}
for label, client in self.clients.items():
try:
r = client.get(url, cookies=cookies)
results[label] = self._verdict(r)
except httpx.HTTPError as e:
results[label] = f"TRANSPORT_ERROR:{type(e).__name__}"
return {
"pinned": results["pinned"],
"rotating": results["rotating"],
"diagnosis": self._interpret(results["pinned"], results["rotating"]),
}
@staticmethod
def _interpret(pinned: str, rotating: str) -> str:
if pinned == "OK" and rotating == "LOGIN_REDIRECT":
return "IP_BOUND_SESSION: exit churn is killing valid cookies; " \
"fix sticky discipline, not the cookie layer"
if pinned == "OK" and rotating == "OK":
return "IP_AGNOSTIC: logouts are cookie-layer; check TTLs/inactivity"
if pinned != "OK" and rotating == "OK":
return "AMBIGUOUS: token may have refreshed; re-probe pinned first"
return "COOKIE_DEAD: session invalid everywhere; re-authenticate"
# usage
# probe = AuthDifferentialProbe(
# pinned_proxy="http://user-session-abc123:pass@gw.thordata.com:8080",
# rotating_proxy="http://user:pass@gw.thordata.com:8080",
# )
# print(probe.diagnose("https://example.com/account/overview",
# cookies=session_cookies))
Two engineering details make this trustworthy in production rather than a lab toy.
First, follow_redirects=False is load-bearing. If the client auto-follows the login redirect, a hard-bound session and a soft one look identical — you get a 200 from the login page either way. You must observe the 302 itself to distinguish "session rejected" from "session followed a normal navigation hop."
Second, run the pinned probe before the rotating one, always. If the rotating probe lands first and the site implements hard binding with server-side invalidation, the rotating probe itself can kill the session, and your pinned probe then reports a dead cookie — you have just converted a diagnostic into an execution. Order matters, and on soft-scoring sites, throttle these probes: they are, by construction, the exact anomaly you are trying to avoid generating.
Wiring it into the fleet
The harness earns its keep as a standing job, not a debugging ritual. The pattern that worked for us:
-
Probe on lease boundaries. Whenever an account's sticky window renews or an exit re-pin is confirmed, run one paired probe before resuming real traffic. If the diagnosis comes back
IP_BOUND_SESSION, the scheduler retires the lease cleanly (log out, new exit, fresh login) instead of bleeding requests into a session that will die on the next rotation anyway. - Probe on logout reports. When a worker reports an unexpected login redirect, the fleet runs the paired probe once before any retry. If pinned also fails, a re-login is genuinely due and retries are pointless. If pinned succeeds, retrying would be actively harmful — every retry through the rotated exit is another anomaly event on the account's risk ledger. Distinguishing these two cases is most of the value.
-
Track the binding class per site. The verdict per site is remarkably stable over weeks. Sites drift between classes only during visible fraud-drives or A/B tests, so a sudden flip in the per-site diagnosis table —
IP_AGNOSTICtoIP_BOUND_SESSION— is an early-warning signal that the site tightened policy, typically days before your logout metrics visibly spike.
One boundary case deserves honesty: a site behind a CDN that varies its edge behavior by geography can give different binding classes per exit country. The paired probe through a rotating exit that happens to land in the same country as your pinned exit may pass while a cross-country rotation fails. If your fleet spans regions, classify by (site, exit_country) and treat the country dimension as part of the diagnosis, not noise.
The broader lesson generalize beyond proxies: when a failure appears only in production, only intermittently, and only for stateful sessions, the fastest route to root cause is not more logging around the suspect layer — it is a controlled experiment that isolates the one variable that differs between the working case and the failing one. In authenticated scraping, that variable is almost always the exit, and the experiment is two requests.
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)