When the Defender Stops Using Rules#

Earlier anti-bot systems matched signatures: known datacenter IPs, known headless flags, known bad headers. Modern bot management supplements those rules with machine-learned anomaly detection trained on enormous volumes of real human traffic. You are no longer matching a hardcoded list; you are trying to look like a sample drawn from the human distribution.

What the Model Sees#

A supervised or semi-supervised model is trained on features like:

  • Fingerprint combinations: canvas hash, WebGL renderer, fonts, screen, timezone, all jointly.
  • Request patterns: inter-arrival timing, header order, HTTP/2 frame structure, connection reuse.
  • Behavioral sequences: navigation path, dwell times, mouse and keyboard telemetry.
  • Population-level statistics: how many requests this IP/subsession makes, and how similar it is to known bots.
  • Account and history features: age, prior challenge outcomes, reputation.

The model outputs a score; the WAF escalates challenges when the score is high. The key insight: it is not checking a single value—it is checking whether your combination of values is statistically plausible.

Rare Combinations Are the Tell#

Human traffic clusters. A real iPhone user in Tokyo uses a set of fingerprints, fonts, timezones, and languages that co-occur constantly in the training data. A scraped profile that mixes, say, a Chrome-on-Windows fingerprint with a Japanese mobile timezone is individually plausible but jointly rare—and rarity is exactly what anomaly detection flags. This is why consistency matters more than any single perfect value. Aim to reproduce a common, well-represented real-world device profile end to end, not an exotic one.

Rate and Volume Features#

Models love volume signals: requests per session, sessions per IP per day, requests to a single endpoint, and deviation from the global inter-arrival distribution. Perfectly regular timing (a fixed sleep) is a strong outlier; genuine human timing is noisy and right-skewed. Distributed, slow crawling with jittered delays and human-shaped session lengths blends with the population far better than high-throughput bursts from a handful of IPs.

Concept Drift#

The model is retrained periodically, and the human distribution shifts (new devices, new browser versions, seasonal behavior). A strategy that evaded detection six months ago may score poorly today because the population moved. This is the ML equivalent of signature churn: your camouflage must be refreshed, and you should monitor success rates for gradual decline.

What Actually Helps#

  • Be a common profile: match a popular device/browser/OS/locale combination exactly, including all its minor idiosyncrasies.
  • Be consistent over time: a stable fingerprint across a session; no mid-request mutations.
  • Be noisy in timing but human in structure: jittered delays, variable dwell times, natural navigation paths.
  • Be modest in volume: stay under per-IP and per-session rate expectations, and distribute across diverse, well-reputed exits.
  • Be patient: aged profiles with clean histories score better than fresh ones.

The Honest Limits#

You are not "defeating" a well-built ML model permanently. At best you are producing samples that fall within the human distribution for the features the model observes, and you will occasionally land in the tail and get challenged. Treat the model as an adversary you continuously re-measure against, and be honest about when a target's detection is strong enough that evasion stops being economical or ethical. The right answer is sometimes to stop, not to escalate.

Sequences Are Harder to Imitate Than Pages#

Anomaly detectors for anti-bot rarely look at a single request in isolation. The signal that differentiates human traffic from replayed automation is the sequence: which links you follow, in what order, with what dwell, after which action, on which path. A perfectly valid single GET and a perfectly valid navigation order are two different things, and it is the second that is expensive to fake because it introduces time, path constraints, and unavoidable variance.

Look at the components that sequence models actually consume:

Feature family What it encodes Why it is fragile to replay
Click path the ordered set of endpoints and referers re-using the same crawl order kills branching entropy
Dwell times time spent before each transition a bot running in headless mode with no think time produces a delta-T distribution with a spike at 0
Session length total pages, distinct paths, depth replaying a short template never regenerates the tail
Timing jitter variance between transitions, not the mean fixed delays are more detectable than bounded noise
State transitions form fills, scrolls, AJAX calls replay rarely reproduces the partial failures humans hit
Client events input cadence, focus changes see browser signals and TLS fingerprinting for why these are noisy for humans and nearly absent for naive bots

The statistical trap is to optimise for the mean of a distribution. A human click path has long tails: they backtrack, they open a new tab, they reload, they search again. A scripted replay has almost no tails. Detectors do not need to catch every outlier; they only need to catch the fact that your variance is wrong relative to a human baseline collected on that target. That baseline is site-specific and changes with the product, which is why general "human-like" delays fail the moment you test against a model trained on that site's logs.

Distribution, Not Delay#

The most common mistake is to pick a single distribution (e.g. exponential with mean 2.0 seconds) and use it for every transition. Human behaviour is multimodal: reading a long article produces a heavy tail, clicking "next" on a carousel produces a tight peak, and a 429 page produces a different distribution entirely. Worse, the distribution depends on where you are in the flow: a first-time visitor behaves differently from an authenticated user returning for the third time that hour, a distinction covered in account warm-up and identity lifecycle.

Sequence-based models also care about auto-correlation. Humans do not generate dwell times as i.i.d. samples; if you spent a long time reading page N, the next click is often slower than if you clicked through in a hurry. A naive generator that samples independently destroys this correlation. So the generation task is not "pick 10 delays" but "generate a Markov chain with site-specific emission distributions and realistic dwell correlations."

The comparison below pits three generators against the same simple sequence model that scores a session on how "human" its transition deltas are. The "human baseline" is a small empirical distribution (read-heavy and click-heavy modes). The three alternatives are representative of what ends up in production scrapers:

import math
import random
from collections import Counter, defaultdict

# Six reference sessions. Each is (route kinds, dwell ms). Traces 0-4 are the
# fit set; trace 5 is held out, because you cannot score a generator without a
# human baseline to score it against.
CORPUS = [
    (("home", "search", "listing", "product", "product", "product", "cart", "home",
      "search", "listing", "product"),
     (4200, 9100, 12800, 33000, 21000, 74000, 5200, 3100, 8800, 6400, 19000)),
    (("home", "listing", "listing", "product", "home", "search", "listing", "product",
      "product", "wishlist"),
     (6100, 14500, 2200, 41000, 2700, 6300, 9800, 87000, 36000, 4400)),
    (("search", "listing", "product", "home", "listing", "product", "product",
      "cart", "checkout", "home"),
     (2400, 7300, 51000, 1900, 11200, 26000, 4300, 6800, 22000, 3600)),
    (("home", "home", "search", "search", "listing", "product", "product", "review",
      "product", "home"),
     (12000, 2400, 5600, 3100, 14700, 21000, 66000, 8400, 3900, 5200)),
    (("home", "search", "listing", "listing", "listing", "product", "wishlist",
      "search", "listing", "product", "cart", "home"),
     (3800, 6600, 4100, 9200, 18500, 58000, 3100, 7100, 15400, 27000, 4900, 2600)),
    (("home", "search", "listing", "product", "review", "product", "product",
      "cart", "home", "search"),
     (5100, 8400, 12900, 34000, 9100, 47000, 22000, 6100, 2800, 7200)),
]

FIT = CORPUS[:5]
CONVERSION = ("cart", "checkout", "wishlist")
SESSIONS = 6


def bigrams(seq):
    return set(zip(seq, seq[1:]))


def grams(seq, n):
    return set(tuple(seq[i:i + n]) for i in range(len(seq) - n + 1))


def jaccard(a, b):
    union = a | b
    return len(a & b) / float(len(union)) if union else 0.0


def fit_markov(traces):
    '''First-order transitions, dwell conditioned on route, self-loop rate read
    off the fit set so the sampler inherits the corpus habit, not its own.'''
    nxt = defaultdict(Counter)
    dwell = defaultdict(list)
    repeats = total = 0
    for routes, dwells in traces:
        for a, b in zip(routes, routes[1:]):
            nxt[a][b] += 1
            total += 1
            if a == b:
                repeats += 1
        for r, d in zip(routes, dwells):
            dwell[r].append(d)
    return {"nxt": nxt, "dwell": dwell, "self_rate": repeats / float(total)}


def route_of(model, current, rng):
    row = dict(model["nxt"].get(current) or {})
    if not row:
        return current
    for _ in range(4):
        total = float(sum(row.values()))
        draw, acc, dest = rng.random(), 0.0, current
        for cand, n in sorted(row.items()):
            acc += n / total
            if draw < acc:
                dest = cand
                break
        if dest != current or rng.random() <= model["self_rate"]:
            return dest
        row.pop(current, None)
    return current


def gen_replay(seed):
    routes, dwells = FIT[1]
    return list(routes), list(dwells)


def gen_marginal(seed):
    '''Match the pooled route marginal: independent draws, transitions gone.'''
    pool = [r for routes, _ in FIT for r in routes]
    dw = [d for _, dwells in FIT for d in dwells]
    rng = random.Random(seed)
    return ([rng.choice(pool) for _ in range(12)],
            [rng.choice(dw) for _ in range(12)])


def gen_markov(seed):
    model = fit_markov(FIT)
    rng = random.Random(seed)
    routes = ["home"]
    for _ in range(11):
        routes.append(route_of(model, routes[-1], rng))
    dwells = []
    for r in routes:
        pool = model["dwell"].get(r) or [10000]
        dwells.append(rng.choice(pool))
    return routes, dwells


def measure(gen, label):
    traces = [gen(700 + i) for i in range(SESSIONS)]
    routes, dwells = traces[0]
    fit_bg = set().union(*[bigrams(r) for r, _ in FIT])
    fit_tg = set().union(*[grams(r, 3) for r, _ in FIT])
    bg = bigrams(routes)
    repeats = sum(1 for a, b in zip(routes, routes[1:]) if a == b)
    mean = sum(dwells) / float(len(dwells))
    cv = math.sqrt(sum((d - mean) ** 2 for d in dwells) / len(dwells)) / mean
    pairs = [jaccard(bigrams(t[0]), bigrams(u[0]))
             for i, t in enumerate(traces) for u in traces[i + 1:]]
    conv = sum(1 for r, _ in traces if any(x in CONVERSION for x in r)) / float(SESSIONS)
    return {
        "generator": label,
        "bigram_recall": len(bg & fit_bg) / float(len(fit_bg)),
        "unseen_trigrams": len(grams(routes, 3) - fit_tg),
        "repeat_rate": repeats / float(len(routes) - 1),
        "dwell_cv": cv,
        "conv_rate": conv,
        "self_sim": sum(pairs) / float(len(pairs)),
    }


def measure_corpus():
    '''Leave-one-out: score each human session against the other four, so the
    baseline is measured the same way a generator is.'''
    recalls, novel, repeats, cvs, convs = [], [], [], [], []
    for held in range(len(FIT)):
        others = [t for i, t in enumerate(FIT) if i != held]
        fit_bg = set().union(*[bigrams(r) for r, _ in others])
        fit_tg = set().union(*[grams(r, 3) for r, _ in others])
        routes, dwells = FIT[held]
        recalls.append(len(bigrams(routes) & fit_bg) / float(len(fit_bg)))
        novel.append(len(grams(routes, 3) - fit_tg))
        repeats.append(sum(1 for a, b in zip(routes, routes[1:]) if a == b) / float(len(routes) - 1))
        mean = sum(dwells) / float(len(dwells))
        cvs.append(math.sqrt(sum((d - mean) ** 2 for d in dwells) / len(dwells)) / mean)
        convs.append(1.0 if any(x in CONVERSION for x in routes) else 0.0)
    sims = [jaccard(bigrams(FIT[a][0]), bigrams(FIT[b][0]))
            for a in range(len(FIT)) for b in range(a + 1, len(FIT))]
    return {"generator": "human (leave-one-out)", "bigram_recall": sum(recalls) / len(recalls),
            "unseen_trigrams": sum(novel) / float(len(novel)), "repeat_rate": sum(repeats) / len(repeats),
            "dwell_cv": sum(cvs) / len(cvs), "conv_rate": sum(convs) / len(convs),
            "self_sim": sum(sims) / float(len(sims))}


COLUMNS = ["generator", "bigram_recall", "unseen_trigrams", "repeat_rate",
           "dwell_cv", "conv_rate", "self_sim"]


def print_row(row):
    print("{:<20} {:>14.2f} {:>17.1f} {:>12.2f} {:>9.2f} {:>10.2f} {:>10.2f}".format(
        row["generator"], row["bigram_recall"], row["unseen_trigrams"],
        row["repeat_rate"], row["dwell_cv"], row["conv_rate"], row["self_sim"]))


def distance(row, base):
    return (abs(row["bigram_recall"] - base["bigram_recall"])
            + abs(row["repeat_rate"] - base["repeat_rate"])
            + abs(row["dwell_cv"] - base["dwell_cv"]) / 2.0
            + abs(row["conv_rate"] - base["conv_rate"])
            + abs(row["self_sim"] - base["self_sim"])
            + abs(row["unseen_trigrams"] - base["unseen_trigrams"]) / 10.0)


def main():
    rows = [measure_corpus()]
    for gen, label in [(gen_replay, "replay one trace"), (gen_marginal, "marginal sampler"),
                       (gen_markov, "markov sampler")]:
        rows.append(measure(gen, label))

    print("fit set: {} human sessions, {} distinct route bigrams".format(
        len(FIT), len(set().union(*[bigrams(r) for r, _ in FIT]))))
    print("each generator scored on {} fresh sessions; row 1 is the human baseline".format(SESSIONS))
    print()
    print("{:<20} {:>14} {:>17} {:>12} {:>9} {:>10} {:>10}".format(*COLUMNS))
    print("-" * 96)
    for row in rows:
        print_row(row)
    print()
    print("distance from the human baseline, lower is better")
    base = rows[0]
    for row in rows[1:]:
        print("  {:<20} {:.3f}".format(row["generator"], distance(row, base)))


main()
fit set: 5 human sessions, 17 distinct route bigrams
each generator scored on 6 fresh sessions; row 1 is the human baseline

generator             bigram_recall   unseen_trigrams  repeat_rate  dwell_cv  conv_rate   self_sim
------------------------------------------------------------------------------------------------
human (leave-one-out)           0.41               4.2         0.21      1.16       0.80       0.38
replay one trace               0.47               0.0         0.22      1.22       1.00       1.00
marginal sampler               0.18               9.0         0.18      1.17       0.83       0.17
markov sampler                 0.47               4.0         0.09      1.05       1.00       0.39

distance from the human baseline, lower is better
  replay one trace     1.342
  marginal sampler     0.986
  markov sampler       0.468

Two numbers stand out. First, the replay of a recorded trace scores almost as poorly as the pure fixed-delay crawl, because it preserves the recorded timing but not the variability the model expects for a new session. Second, the marginal (i.i.d.) generator improves on fixed delay but still loses most of its score because it flattens the modes and removes the structure of the baseline. The Markov generator that samples from the same empirical modes recovers most of the human score by preserving the mixture of behaviours, but even then it does not reach the leave-one-out baseline: the baseline retains the session-specific correlation that a canned generator cannot.

The point is not to reach 1.0. The point is that the gap between replay and a plausible generative model is measurable, and models trained on the target will exploit the parts of that gap you did not think to model.

Mode Collapse and the Temptation to Overfit#

When you build a generator to beat a local model, the easiest trap is mode collapse: your generator learns to produce the median session and stops producing the long tail. Detectors trained on real traffic see the long tail as normal; your median-only sessions look unnaturally regular. Worse, if you train your generator against the very features the detector uses, you are in a local arms race against a moving target you cannot see. The defender can add a new feature tomorrow and your tuned generator becomes your worst-performing one overnight.

A more robust discipline is to model constraints rather than to model the detector:

  • Respect referer chains. Do not jump from /checkout to /about unless the product allows it. Broken referers are cheap to check.
  • Respect pagination and filters. Humans rarely apply every filter in the same order on every session.
  • Respect the product graph. If an item is out of stock, humans behave differently than bots; replaying a session that saw stock creates an implausible path when stock changes.
  • Respect time-of-day and geography. A session that does five searches in thirty seconds at 03:00 local time from a residential exit in a country where the site sees little overnight traffic is implausible even if each individual request is clean.

These are structural constraints. They do not require knowing the detector weights; they require knowing the product and the plausible human behaviour on it. They are also harder to satisfy at scale because they couple your generator to the domain, not to a statistical trick.

Realistic Session Construction#

A practical way to avoid the obvious artefacts is to treat sessions as graphs of possible actions with transition probabilities estimated from coarse human telemetry, not from scraped templates. But even that is not enough unless you also model the abort paths:

  • Typos and corrections. Humans mistype search terms and fix them.
  • Scroll and viewport. Viewport size varies, and scroll depth correlates with dwell time.
  • JavaScript failures and retries. A real browser sometimes fails to load a resource and retries; naive automation does not.
  • Multi-tab and back/forward. The History API and BFCache create patterns that headless crawlers rarely reproduce.
  • Form validation errors. Humans submit forms with missing fields and see errors; bots that always submit valid forms miss an entire class of transitions.

Most of these are covered in browser signals and TLS fingerprinting and in managed challenge flows. The key point: the more of these you omit, the more your sessions cluster into the "scripted" region of the feature space. The more you include, the more compute and brittleness you introduce, because simulating a real browser's edge cases is expensive and breaks on every site redesign.

Concept Drift Is Not Optional#

The baseline against which you are measured moves constantly. A product launch changes click paths. A sale changes dwell times. Seasonality changes traffic mix. A new A/B test changes the page layout and thus the referer graph. Any generator that was tuned three weeks ago is tuned to last month's traffic, not this week's. This is the same drift problem discussed in keeping current and in evading ML anomaly detection's parent lesson, but at the generation layer it compounds: you must detect when your generated sessions have stopped matching the real distribution, and you must be able to retrain without leaking your detector.

The operational rule is: measure your own generator against a fresh holdout of human sessions at least weekly for each high-value target, and track at least three metrics: the mean sequence score, the 10th percentile, and the entropy of transition types. If any of them moves by more than a threshold you chose before you saw the movement, treat it as drift and do not assume the old parameters still work.

Why This Is the Hardest Layer#

There are three reasons this is genuinely difficult:

  1. You cannot reliably test in production without being measured. Every request you send is potentially feeding the very model you are trying to evade. Running large-scale A/B tests of your evasion strategy is often indistinguishable from the behaviour you are trying to avoid.
  2. The feedback loop is adversarial and asymmetric. The defender can observe all your traffic and add features silently; you can observe only what the site returns to you.
  3. The cost of fidelity scales superlinearly. Getting 90% of the way there is cheap (add jitter). Getting the last 5% -- the long tail, the correlations, the abort paths -- costs an order of magnitude more in engineering time and in browser resources, and that last 5% is exactly where modern detectors live.

The pragmatic conclusion is the one the parent lesson states clearly: evading a well-trained, site-specific anomaly detector is not a matter of adding a random sleep. It is a matter of reproducing the full messy reality of human browsing, and doing so at a scale and a price that makes the work economical. For many targets, the economical answer is not to evade the ML detector at all, but to route that work through channels where the detector's constraints do not apply -- the official APIs and alternative data path being the obvious one when it exists.