DEV Community

Greta
Greta

Posted on

Your Scraper Logged Out Three Days Ago and You Didn't Notice: Debugging Authenticated Sessions

Your Scraper Logged Out Three Days Ago and You Didn't Notice: Debugging Authenticated Sessions

The worst failure mode in logged-in scraping is not the block. A block is loud — 403s, challenge pages, stack traces in your alerting channel. The worst failure mode is silent deauthentication: your session died days ago, the site kept returning 200s, and your pipeline kept "successfully" collecting data that is now worthless — logged-out pricing, region-default search results, a public view of a page that should have been personalized. You find out when a downstream consumer asks why the data looks weird, and by then three days of the dataset are contaminated and you can't even cleanly separate the good rows from the bad ones.

The core claim of this article: you cannot detect logged-out state from status codes — you have to probe for it explicitly, on every request, with content-level auth markers, and you need a signal taxonomy that distinguishes five different failure modes that all look identical from the outside. Once you have that taxonomy, "debugging a logged-in scraper" stops being archaeology and becomes reading a dashboard.

Why status codes lie

The sites that matter for logged-in scraping are almost all SPA-era applications with server-side rendering fallbacks and A/B testing infrastructure. What that means mechanically:

  • You request /account/orders. Your session cookie is expired. The server doesn't 401 — it 302s to the login page, or serves the page with the logged-out variant.
  • If your HTTP client follows redirects (requests does by default), you now GET /login and receive a perfectly healthy 200 containing a login form.
  • Your extractor runs on the login page HTML. If your selectors are loose ("find the div with class order-list"), they return an empty list — and an empty list is not an exception, so your pipeline records "no orders today" and moves on.

That's the classic case, and there are four more that present nearly identically. Here's the taxonomy I use, because each has a different fix and a different blast radius:

  1. Hard logout — cookies invalid. Site redirects to auth. Fix: re-login lane.
  2. Shadow deauth — cookies accepted, but server silently serves the logged-out variant on the same URL (common with token-binding mismatches after your exit IP changed). No redirect at all. Hardest to catch without content markers.
  3. CSRF desync — session cookie valid, but the CSRF token your code cached no longer matches the server-side binding. GETs work; POSTs fail with 403 or a JSON error envelope. Often misdiagnosed as "we're getting blocked" — it's actually your own token cache.
  4. Soft shadow-ban — session valid, requests succeed, but the account sees degraded data: filtered search results, missing inventory, "personalized" prices. Only detectable by cross-checking known values.
  5. Rate-limit decay — intermittent 429s mixed with 200s; the session survives but throughput decays. Different fix entirely (pacing, not auth).

The first three are session-state bugs. The fourth is a trust bug. The fifth is a pacing bug. Teams that don't separate them thrash: they rotate IPs to fix a CSRF desync, re-login to fix a rate limit, and burn perfectly good sessions chasing a shadow-ban that was actually a logged-out view all along.

Content-level auth markers

The fix for silent deauth is to define, per target, a set of markers that answer "was this response authenticated?" from the response body — not the status code. The markers live next to your extraction rules because they're site-specific knowledge, and they should be versioned with them:

import re
from dataclasses import dataclass

@dataclass
class AuthMarkers:
    """Content signals that distinguish logged-in from logged-out responses."""
    logged_in: list[str]      # substrings present only when authenticated
    logged_out: list[str]     # substrings present only when anonymous
    json_auth_key: str = ""   # for API endpoints: JSON field proving identity

def classify_auth(resp_text: str, m: AuthMarkers) -> str:
    hits_in = any(s in resp_text for s in m.logged_in)
    hits_out = any(s in resp_text for s in m.logged_out)
    if hits_in and not hits_out:
        return "authenticated"
    if hits_out and not hits_in:
        return "anonymous"
    if not hits_in and not hits_out:
        return "unknown"   # A/B variant, redesign, or extraction drift
    return "contradictory" # both present -> interstitial/challenge; treat as failure
Enter fullscreen mode Exit fullscreen mode

Good markers are boring and stable: the account email in a header element, a "Sign out" vs "Sign in" link label, a data-user-id attribute, a JSON response field like "viewer": {"id": ...} versus "viewer": null. Avoid anything an A/B test will move (button copy changes weekly; structural attributes survive redesigns better than text does — though redesigns do happen, which is why unknown is a first-class result rather than an exception).

The contradictory state deserves a note: it catches the cases where the site serves an interstitial (consent wall, region picker, CAPTCHA challenge) that contains both marker sets. If you treat that as authenticated you poison data; treat it as a failure and route it to your challenge-handling path.

Then wire the classifier into the request loop and make the outcome a first-class metric:

from collections import Counter, deque

class SessionHealthTracker:
    """Rolling auth-classification per session; alerts on drift, not on single events."""

    def __init__(self, window: int = 50):
        self.window = window
        self.history: dict[str, deque] = {}

    def record(self, session_id: str, classification: str) -> str | None:
        h = self.history.setdefault(session_id, deque(maxlen=self.window))
        h.append(classification)
        if len(h) < 10:
            return None
        c = Counter(h)
        anon_ratio = c["anonymous"] / len(h)
        if anon_ratio > 0.9:
            return "HARD_LOGOUT"
        if 0.1 < anon_ratio <= 0.9:
            return "FLAPPING"          # shadow deauth or redirect-following bug
        if c["unknown"] / len(h) > 0.5:
            return "MARKER_DRIFT"      # site redesign or A/B — fix your markers
        return None
Enter fullscreen mode Exit fullscreen mode

Two design decisions in that tracker took me embarrassingly long to learn. First, alert on ratios over a window, never on single responses — one anonymous response out of fifty is a login page redirect that your client followed by mistake; forty out of fifty is a dead session. Second, FLAPPING — the intermittent mix of authenticated and anonymous responses from one session — is the single most diagnostic signal in the taxonomy. A consistent pattern means your cookie is simply dead. A flapping pattern means something stateful is wrong: the session is IP-bound and your exit IP is unstable, the site is doing progressive deauth (some endpoints validate more aggressively than others), or you have a race where two workers share one cookie jar and one keeps refreshing it under the other.

The redirect trap, fixed

While we're here: stop letting your HTTP client follow redirects blindly. Redirects are data about session state:

import requests

def fetch_logged_in(session: requests.Session, url: str, markers: AuthMarkers):
    r = session.get(url, allow_redirects=False, timeout=15)
    if r.status_code in (301, 302, 303, 307, 308):
        loc = r.headers.get("Location", "")
        if any(p in loc for p in ("login", "signin", "auth", "session")):
            return {"state": "anonymous", "redirect": loc}
        r = session.get(loc, allow_redirects=False, timeout=15)  # follow once, manually
    classification = classify_auth(r.text, markers)
    return {"state": classification, "status": r.status_code}
Enter fullscreen mode Exit fullscreen mode

allow_redirects=False with one manual hop turns the most common silent failure into an explicit anonymous classification with a redirect target attached — and the redirect target tells you which auth wall you hit, which distinguishes an expired session from a re-auth requirement from a region gate.

Post-mortem: the three-day contamination

The scenario in the title, dissected with this instrumentation: the pipeline's SessionHealthTracker would have shown the account flipping to 100% anonymous at a specific timestamp. That timestamp is the logout. The next question is why, and the honest answer requires the session record: my money, in the case I actually lived through, was on the exit IP. The session was IP-bound (the site's token binding included the /24), the sticky proxy window had expired at 03:14 that night, a new exit arrived from a different subnet, and the site silently degraded the session to logged-out rather than invalidating it — shadow deauth, taxonomy case 2. The fix wasn't "log in again" (that would happen weekly forever); it was pinning the proxy session lifetime above the site's session lifetime and alerting when the exit subnet changed under a live session.

Which is the general lesson. Debugging logged-in scrapers is not about finding the bug — with the marker taxonomy, the bug finds itself within fifty requests. It's about having the telemetry to ask the second question: what changed in the session's environment at the moment classification flipped? Exit IP, user-agent, token age, request cadence — log them alongside the auth classification, and the answer is usually a join away.

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)