How WAFs Decide Who You Are#

A modern WAF is not a gate; it is a bookkeeper. Every request and every session produces a score that accumulates and decays over time. Understanding the ledger model tells you exactly which behaviors matter and which are cosmetic.

The Ledger#

Each session has a running risk score fed by dozens of weighted signals: IP reputation, TLS fingerprint quality, JS challenge success, biometrics, historical hit rates, account age, and past challenge patterns. Signals decay: a clean hour of browsing overwrites an old anomaly. The WAF acts only when the score crosses action thresholds (soft challenge, interstitial, CAPTCHA, block). The implication is powerful: a habitually clean low-risk session can absorb occasional imperfect requests, while a habitually flagged session is poisoned even if every new request is perfect.

Session vs. Connection#

A session is a logical identity—a browser profile, a set of cookies, a history of visited pages. A connection is a physical TLS link. WAFs link them via cookies and fingerprint. If you want a session to stay low-risk, keep the fingerprint, the cookies, and the IP pool region stable for the lifetime of the profile. A login, then five minutes of inactivity, then nineteen detail-page requests, maintains the ledger as one coherent human-like day. Opening a fresh browser identity for every request fragments the ledger into dozens of suspicious snippets.

Account Age and Warmup#

For authenticated scraping (pricing lists, seat inventories, account-gated feeds), the ledger includes account-level signals: how old the account is, how many devices/IPS have logged in, and prior automated-looking activity. New accounts that immediately scrape are textbook detection triggers. Many operations "warm up" accounts: register, log in from one stable IP, browse like a person for days, and only then point them at high-volume automation. Warm-up time is a direct insurance premium against account bans.

A Habitually Clean Profile Is a Shield#

The ledger model has a strategic implication: you should invest in keeping some profiles pristine. A profile that has browsed the target site like a human for weeks, accumulated logins, read a few articles, and never scraped a single row will carry a very low baseline risk score. When you finally point that profile at a hard scraping job, its accumulated trust lets it absorb mistakes that would instantly kill a brand-new profile. Cultivate a stable of "aged" accounts the way you would cultivate a good credit score.

Close the Loop With Real Telemetry#

You cannot manage a ledger you cannot see. Many WAFs expose some observable metadata to the client—challenge frequency, challenge type, and the presence of clearance cookies. Instrument those signals per profile: count challenges per hundred requests, track how often the profile returns 403s, and record how long clearances last. Over time these create a per-profile "health score" that mirrors the WAF's own ledger and lets you rotate work away from degrading profiles before they are banned.

Sessions Should End, Too#

Humans do not maintain a single unbroken session for 30 hours. They log in, do a batch of work, close the tab, and come back later. Give long-running profiles a daily rhythm: active windows, idle gaps, and occasional multi-hour absences. Matching the WAF's expected activity curve keeps your traffic inside the "human-shaped" envelope.

The Lifetime of a Profile#

Track four lifecycle variables for every profile: 1. Age (how long the identity has "existed"). 2. Fingerprint stability (has anything changed?). 3. Trust level (accumulated risk score trajectory). 4. Ban state (soft-blocked, CAPTCHA-gated, or hard-banned).

Operate each profile within its trust envelope and rotate work away from profiles whose score is climbing. A fleet with tiered trust—new, maturing, mature profiles—lets you keep scraping through a maturing tier while the mature tier does the heavy lifting.

Score Composition: What a Hit Is Worth#

Risk engines do not usually publish their weights, but the structure is stable enough to reason about. A score is a sum of named contributions, each of which is either a hard signal, a decaying behavioural signal, or a volumetric signal.

  • Hard signals are contradictions rather than behaviours: a TLS ClientHello that does not match the claimed browser build, a client-hint header inconsistent with the UA string, a canvas bundle that names an Intel GPU on an Android profile. They do not decay. A session with one hard contradiction is suspicious forever, because the contradiction is in the configuration rather than in the activity. See for the cross-checks that produce them and for the transport ones.
  • Decaying signals are things that were true and stopped being true: IP reuse across accounts, a burst of requests, a solve API sighting. Each has a half-life, and each contributes a value that falls exponentially toward a floor.
  • Volumetric signals are rate-derived and near-instantaneous: requests per second, requests per session, pages per minute. They spike and return to zero, which makes them useful in the moment and useless later.

The practical difference shows up the moment you try to recover a profile. You cannot fix a hard contradiction by behaving better, and you cannot fix a behavioural term by reconfiguring a browser. Knowing which of the two you are carrying tells you which lever exists — and that is the entire content of the trajectory below.

Decay Is Where the Strategy Lives#

The trap for an operator is assuming that good behaviour reduces the score proportionally. It does not, because decay is per-term with different half-lives and different floors:

import math

HALF_LIFE_S = 86400.0

CONSTANT_TERMS = (
    ("datacentre_asn", 7.5),
    ("tls_ja3_mismatch", 15.6),
    ("headless_marker", 0.5),
    ("burst_rate", 0.0),
)

DECAYING_TERMS = (
    ("proxy_pool_reuse", 37.0, 4.0, HALF_LIFE_S),
    ("solver_api_seen", 8.0, 0.0, HALF_LIFE_S / 12.0),
)

TIMELINE = (0, 120, 300, 420, 1800, 3600, 21600, 43200, 86400, 172800)
ALLOW_BELOW = 40.0
BLOCK_ABOVE = 70.0


def decay(start, floor, half_life, t):
    return floor + (start - floor) * (0.5 ** (t / half_life))


def score(t):
    parts = [(n, v) for n, v in CONSTANT_TERMS]
    for name, start, floor, hl in DECAYING_TERMS:
        parts.append((name, decay(start, floor, hl, t)))
    return parts, sum(v for _, v in parts)


def band(total):
    if total > BLOCK_ABOVE:
        return "block"
    if total >= ALLOW_BELOW:
        return "challenge"
    return "allow"


print("Risk-score trajectory for one profile that stops misbehaving")
print("decaying terms: proxy_pool_reuse half-life %.0f s, solver_api_seen half-life %.0f s"
      % (HALF_LIFE_S, HALF_LIFE_S / 12.0))
print("constant terms: " + ", ".join("%s %.1f" % (n, v) for n, v in CONSTANT_TERMS))
print("")
print("%8s %12s %10s %10s %10s %10s" % (
    "t (s)", "total", "pool", "solver", "constant", "band"))
for t in TIMELINE:
    parts, total = score(t)
    lookup = dict(parts)
    pool = lookup["proxy_pool_reuse"]
    solver = lookup["solver_api_seen"]
    const = sum(v for n, v in parts
                if n not in ("proxy_pool_reuse", "solver_api_seen"))
    print("%8d %12.1f %10.1f %10.1f %10.1f %10s" % (
        t, total, pool, solver, const, band(total)))
print("")

lo, hi = 0.0, 40.0 * HALF_LIFE_S
for _ in range(200):
    mid = (lo + hi) / 2.0
    if score(mid)[1] > ALLOW_BELOW:
        lo = mid
    else:
        hi = mid
cross = (lo + hi) / 2.0
print("time to fall below the allow band: %.0f s = %.2f days" % (cross, cross / 86400.0))
print("")

lo, hi = 0.0, 40.0 * HALF_LIFE_S
for _ in range(200):
    mid = (lo + hi) / 2.0
    if dict(score(mid)[0])["proxy_pool_reuse"] > 5.0:
        lo = mid
    else:
        hi = mid
pool_day = (lo + hi) / 2.0
print("time for the pool-reuse term alone to fall below 5: %.0f s = %.2f days"
      % (pool_day, pool_day / 86400.0))
print("")

parts, total = score(172800.0)
print("live contribution breakdown at t=172800 s (%.1f hours)" % (172800.0 / 3600.0))
print("%-22s %8s %s" % ("term", "value", "decays?"))
for name, value in sorted(parts, key=lambda p: -p[1]):
    decays = "yes" if name in ("proxy_pool_reuse", "solver_api_seen") else "no"
    print("%-22s %8.1f %s" % (name, value, decays))
print("%-22s %8.1f %s" % ("TOTAL", total, band(total)))
print("")

floor_total = sum(v for _, v in CONSTANT_TERMS) + 4.0
print("permanent floor from hard contradictions: %.1f (%s)"
      % (floor_total, band(floor_total)))
print("a clean residential session with coherent signals scores 0.0 (%s)" % band(0.0))
print("the same profile never returns to 0.0: the floor is structural, not behavioural")
Risk-score trajectory for one profile that stops misbehaving
decaying terms: proxy_pool_reuse half-life 86400 s, solver_api_seen half-life 7200 s
constant terms: datacentre_asn 7.5, tls_ja3_mismatch 15.6, headless_marker 0.5, burst_rate 0.0

   t (s)        total       pool     solver   constant       band
       0         68.6       37.0        8.0       23.6  challenge
     120         68.5       37.0        7.9       23.6  challenge
     300         68.3       36.9        7.8       23.6  challenge
     420         68.2       36.9        7.7       23.6  challenge
    1800         66.9       36.5        6.7       23.6  challenge
    3600         65.3       36.1        5.7       23.6  challenge
   21600         56.3       31.7        1.0       23.6  challenge
   43200         51.1       27.3        0.1       23.6  challenge
   86400         44.1       20.5        0.0       23.6  challenge
  172800         35.9       12.2        0.0       23.6      allow

time to fall below the allow band: 122008 s = 1.41 days

time for the pool-reuse term alone to fall below 5: 435836 s = 5.04 days

live contribution breakdown at t=172800 s (48.0 hours)
term                      value decays?
tls_ja3_mismatch           15.6 no
proxy_pool_reuse           12.2 yes
datacentre_asn              7.5 no
headless_marker             0.5 no
solver_api_seen             0.0 yes
burst_rate                  0.0 no
TOTAL                      35.9 allow

permanent floor from hard contradictions: 27.6 (allow)
a clean residential session with coherent signals scores 0.0 (allow)
the same profile never returns to 0.0: the floor is structural, not behavioural

Four things in that output should change how you plan.

The first seven minutes are worthless. From t=0 to t=420 the total moves 0.4 points. solver_api_seen has a 2-hour half-life and proxy_pool_reuse has a one-day half-life, so nothing you do immediately moves anything. If your fleet is burning profiles, the damage is done at the moment of the violation, not during the recovery.

The crossing point is 1.41 days, not 1.41 hours. Both to get out of the challenge band and to fall into the allow band you are waiting on a multi-day half-life, which is why the warm-up model in is measured in days and not in sessions.

The floor never goes to zero. The 23.6 points of constant terms are still there at 48 hours. A profile with a TLS mismatch is permanently suspicious no matter how it behaves, and a datacentre ASN is permanent. The only fix is a different profile, not a quieter one. The permanent floor of 27.6 is still inside the allow band here — but on a site whose allow threshold sits lower, it would not be, and then no amount of behavioural discipline helps.

The dominant term decays on a different timescale from everything else. solver_api_seen is effectively gone in six hours; proxy_pool_reuse needs 5.04 days to fall below 5. So a profile that tripped a solve-API sighting recovers quickly, and a profile that came from a shared IP pool recovers not at all on any useful timescale. Those are two different problems requiring two different responses: the first is a cooldown, the second is a replacement. Pool hygiene is covered in and .

What Each Term Costs to Remove#

Working down the sorted contribution list from the trajectory output, each term has a different remedy and a different cost, and conflating them is why recovery efforts stall.

Term Remedy Cost to apply Reversible by waiting?
burst_rate pace the request rate one variable, minutes yes, immediately
solver_api_seen stop using the vendor one integration change yes, 2 h half-life
proxy_pool_reuse reduce profiles per IP needs egress budget yes, but 1-day half-life
datacentre_asn change the network procurement lead time no
tls_ja3_mismatch fix the transport stack rewrite or patch no
headless_marker patch the browser ongoing maintenance no

The bottom half is the operational reality. Three of the six terms cannot be removed by any amount of behavioural discipline, and two of those — the ASN and the transport mismatch — cannot be removed by any amount of engineering either, only by changing what you are. When a profile is carrying a hard contradiction, the correct action is retirement, not optimisation, because the floor term will still be there after every other term has decayed to zero.

Two practical implications:

Sequence remedies by decay rate, not by contribution size. Sorting by size and attacking the largest term first is right when the largest term is also the fastest to fix. When it is not — a large proxy_pool_reuse term that needs egress budget against a small burst_rate term you can fix with one variable — the cheap fix comes first, because it changes the score today while the budget conversation runs.

Distinguish "needs procurement" from "needs engineering". The ASN row is a money problem and the TLS row is a code problem. Mixing them in a single backlog item means the code work waits on the money, which is backwards, since the code work is unblocked.

The Perfect Request in a Poisoned Session#

Session-level state is the part of the model that punishes you for other people's behaviour, and it is worth separating from the terms above.

Third-party joins. Ad blockers, corporate proxies, ISPs with transparent caches and — most importantly — residential proxy providers all introduce shared identifiers into the session. A VPN exit shared by thousands of subscribers, a device-ID cookie set by a bundled library, a shared corporate NAT with a common Accept-Language and timezone: each of these is a legitimate origin-side join that turns your session into an aggregate. The session-level equivalent of the cardinals problem in .

Per-session aggregation. A WAF holds session state for days, so a single burst poisons the session's score regardless of what follows. The mitigation is not smoothing — it is that a session's volume of bad evidence is bounded by your own pacing. A crawler that reads 500 pages in 20 minutes has no recovery path inside the session, because the accumulated volume is already above the block threshold and further good behaviour only decays individual terms, not the aggregate count.

Cross-session correlation. The account-keyed term: N accounts from one IP, or one fingerprint across N accounts. This is the term that makes shared-pool economics visible, and it is why pool rotation must be slower than the correlation window rather than faster. Getting this wrong looks like "my scores improved after I added more IPs" and is actually "I made the correlation worse and the provider's aggregate score went up".

Practical consequences. Keep sessions long-lived and low-volume rather than short and high-volume. Treat any third-party script or extension as a potential session contaminant and audit it. Keep per-profile, per-IP and per-device volume below the level at which correlation windows fire, which are different limits — the aggregate is usually the binding one. And measure the session's score distribution over hours, not minutes, so that a recovery is visible when it happens instead of being assumed; the measurement side is in .

The Defender's False-Positive Arithmetic#

For the defender, the same model is a threshold-placement problem with an explicit optimum, and the optimum is set by the value of a legitimate session, not by how much bot traffic there is:

import math

DAILY_LEGIT = 658000
DAILY_BOTS = 8000
LEGIT_MU, LEGIT_SD = 26.0, 15.0
BOT_MIX = ((0.62, 33.0, 15.0), (0.38, 78.0, 12.0))
COST_LEGIT = 0.08
COST_BOT = 0.35
ALLOW_BELOW = 40.0
BLOCK_ABOVE = 70.0

SITES = (
    ("large retail, 20M/mo", 658000, 8000, 0.08, 0.35),
    ("mid-size, 2M/mo", 66000, 2100, 0.06, 0.35),
    ("low-value blog, 200k/mo", 6600, 90, 0.02, 0.35),
    ("high-LTV SaaS, 2M/mo", 66000, 2100, 0.90, 0.35),
)


def cdf(x, mu, sd):
    return 0.5 * (1.0 + math.erf((x - mu) / (sd * math.sqrt(2.0))))


def tail_above(cut, mu, sd):
    return 1.0 - cdf(cut, mu, sd)


def bot_below(cut):
    return sum(w * cdf(cut, mu, sd) for w, mu, sd in BOT_MIX)


def costs(cut, legit_n, bot_n, c_legit, c_bot):
    blocked = legit_n * tail_above(cut, LEGIT_MU, LEGIT_SD)
    missed = bot_n * bot_below(cut)
    return blocked, missed, blocked * c_legit + missed * c_bot


def band(total):
    if total > BLOCK_ABOVE:
        return "block"
    if total >= ALLOW_BELOW:
        return "challenge"
    return "allow"


def sweep(legit_n, bot_n, c_legit, c_bot):
    best = None
    rows = []
    for i in range(71):
        cut = 10.0 + i
        blocked, missed, total = costs(cut, legit_n, bot_n, c_legit, c_bot)
        rows.append((cut, blocked, missed, total))
        if best is None or total < best[3]:
            best = (cut, blocked, missed, total)
    return rows, best


print("Defender false-positive economics: choosing where to put the block threshold")
print("legit sessions/day %d, score ~ N(%.0f, %.0f)" % (DAILY_LEGIT, LEGIT_MU, LEGIT_SD))
print("bot sessions/day  %d, score mixture %s" % (
    DAILY_BOTS, " + ".join("%d%% N(%.0f,%.0f)" % (w * 100, mu, sd)
                            for w, mu, sd in BOT_MIX)))
print("cost per blocked legit session $%.2f, cost per missed bot session $%.2f"
      % (COST_LEGIT, COST_BOT))
print("")
rows, best = sweep(DAILY_LEGIT, DAILY_BOTS, COST_LEGIT, COST_BOT)
print("%6s %14s %14s %14s %10s" % ("cut", "legit blocked", "bots missed", "cost/day", "band"))
for cut, blocked, missed, total in rows:
    if int(cut) % 10 == 0 or abs(cut - best[0]) < 0.51:
        print("%6.0f %14.0f %14.0f %14.0f %10s" % (
            cut, blocked, missed, total, band(cut)))
print("")
print("optimum cut %.0f: %.0f legit sessions blocked, %.0f bots missed, $%.0f/day"
      % (best[0], best[1], best[2], best[3]))
print("  that is %.3f%% of legitimate traffic collateral damage"
      % (100.0 * best[1] / DAILY_LEGIT))
print("  and it still misses %.0f%% of all bot traffic" % (100.0 * best[2] / DAILY_BOTS))
print("")
best_cont = min(((10.0 + i * 0.05,
                  costs(10.0 + i * 0.05, DAILY_LEGIT, DAILY_BOTS,
                        COST_LEGIT, COST_BOT)[2]) for i in range(1601)),
                key=lambda p: p[1])
print("cost-minimising cut in continuous form: %.2f, cost $%.0f/day"
      % (best_cont[0], best_cont[1]))
print("")
print("%-26s %8s %12s %12s %14s" % ("site", "cut", "legit/day", "bots/day", "cost/day"))
for label, legit_n, bot_n, c_legit, c_bot in SITES:
    _, site_best = sweep(legit_n, bot_n, c_legit, c_bot)
    print("%-26s %8.0f %12.0f %12.0f %14.0f" % (
        label, site_best[0], site_best[1], site_best[2], site_best[3]))
print("")
print("cost per legit session moves the optimum far more than bot volume does:")
print("  blog at $0.02 -> cut %.0f keeps %.0f legit sessions per day"
      % (sweep(6600, 90, 0.02, 0.35)[1][0], sweep(6600, 90, 0.02, 0.35)[1][1]))
print("  SaaS at $0.90 -> cut %.0f blocks %.0f legit sessions per day"
      % (sweep(66000, 2100, 0.90, 0.35)[1][0], sweep(66000, 2100, 0.90, 0.35)[1][1]))
print("")
blocked30, missed30, total30 = costs(30.0, DAILY_LEGIT, DAILY_BOTS,
                                     COST_LEGIT, COST_BOT)
blocked70, missed70, total70 = costs(70.0, DAILY_LEGIT, DAILY_BOTS,
                                     COST_LEGIT, COST_BOT)
print("aggressive (cut 30) vs permissive (cut 70):")
print("  cut 30: %.0f legit blocked, %.0f bots missed, $%.0f/day"
      % (blocked30, missed30, total30))
print("  cut 70: %.0f legit blocked, %.0f bots missed, $%.0f/day"
      % (blocked70, missed70, total70))
print("  moving 30 -> 70 cuts blocked legit sessions %.1f%% and modelled cost %.1f%%"
      % (100.0 * (1.0 - blocked70 / blocked30), 100.0 * (1.0 - total70 / total30)))
print("  the bot traffic that gets through grows only %.1f%%"
      % (100.0 * (missed70 / missed30 - 1.0)))
Defender false-positive economics: choosing where to put the block threshold
legit sessions/day 658000, score ~ N(26, 15)
bot sessions/day  8000, score mixture 62% N(33,15) + 38% N(78,12)
cost per blocked legit session $0.08, cost per missed bot session $0.35

   cut  legit blocked    bots missed       cost/day       band
    10         563866            310          45218      allow
    20         431268            958          34837      allow
    30         259820           2087          21516      allow
    40         115363           3373          10410  challenge
    50          36058           4352           4408  challenge
    60           7702           4985           2361  challenge
    68           1681           5526           2069  challenge
    70           1103           5694           2081  challenge
    80            105           6677           2345      block

optimum cut 68: 1681 legit sessions blocked, 5526 bots missed, $2069/day
  that is 0.256% of legitimate traffic collateral damage
  and it still misses 69% of all bot traffic

cost-minimising cut in continuous form: 67.95, cost $2069/day

site                            cut    legit/day     bots/day       cost/day
large retail, 20M/mo             68         1681         5526           2069
mid-size, 2M/mo                  62          541         1340            502
low-value blog, 200k/mo          61           65           57             21
high-LTV SaaS, 2M/mo             74           45         1593            598

cost per legit session moves the optimum far more than bot volume does:
  blog at $0.02 -> cut 61 keeps 65 legit sessions per day
  SaaS at $0.90 -> cut 74 blocks 45 legit sessions per day

aggressive (cut 30) vs permissive (cut 70):
  cut 30: 259820 legit blocked, 2087 bots missed, $21516/day
  cut 70: 1103 legit blocked, 5694 bots missed, $2081/day
  moving 30 -> 70 cuts blocked legit sessions 99.6% and modelled cost 90.3%
  the bot traffic that gets through grows only 172.8%

The uncomfortable line is the last one. Moving the threshold from 30 to 70 eliminates 99.6% of the collateral damage and lowers the modelled cost, while letting 172.8% more bot traffic through. That is the correct answer economically and it is also the answer that gets a security team fired, because "we caught more bots" is the metric on the review slide. A defender who optimises the modelled cost is optimising the wrong objective; the point of the exercise is to see the shape of the trade before deciding which side of it you are actually on.

The site table is the part that transfers. The blog and the SaaS platform have the same bot rate and the same score distributions, and their optimal cuts differ by 13 points because a blocked SaaS login is worth 11 times what a blocked blog pageview is worth. Anything that moves the legitimate-session value — a login flow, a checkout, a lead form — moves the threshold, and the movement is not proportional to the traffic involved.

Designing a Profile That Ages Well#

Everything above describes how to survive. This section is about the property you should want instead: a profile whose score does not need rescuing, because it never accumulated the terms in the first place. Four design rules, each traceable to a term in the trajectory output.

Assign one egress per profile, permanently. proxy_pool_reuse is the largest term in the output and the slowest to decay, and it exists only because profiles share addresses. One address per profile eliminates it structurally rather than waiting it out. The cost is that address count and profile count are the same number, which is a budget conversation rather than a code change — see .

Fix the transport stack before the browser stack. tls_ja3_mismatch is a permanent 15.6 and it is the second-largest contributor at the end of the table. A browser whose ClientHello does not match its claimed build carries that forever, and no amount of JS-level hardening moves it.

Never let the fleet share an egress with another operator's traffic. This is a purchasing constraint, not a technical one, and it is invisible until the score is already high: a residential provider's address has other customers' behaviour aggregated into whatever the site's history says about it. The IP-reputation and grading surfaces are in and .

Keep the profile's own history boring. Volume terms, rate terms and burst terms all accumulate into session state that decays only slowly. A profile that reads 40 pages in an evening and then stops is easy; a profile that alternates between 500 pages and 2 pages for a month is not, because the aggregate is what the model sees. The decay and backoff procedures for when this has already gone wrong are in .

The result is a profile that starts at a low score, because it carries none of the six terms in the table at their starting values, and therefore has no floor to escape from. Its total is a function of configuration quality rather than of history, which is the only version of this problem that is actually solvable by engineering.

Reasoning About a Score You Cannot See#

You will never see your own score. What you can see is the band you are in, and the useful strategies are all about reducing the uncertainty rather than about guessing a number.

Infer the band from the response. This is the core technique. The three responses you can observe map to three bands: content, a soft challenge, and a hard block. A soft challenge at a low rate tells you your score is in the challenge band and stable. A sudden shift from content to challenge after a volume change tells you a volumetric term fired. A hard block that does not clear after a known cooldown tells you a hard term is present — a contradiction, not a rate. is the debugging workflow for turning that into per-request attribution.

Bisect by subtraction. If a session is challenged, change one thing and observe: same profile, different IP. Challenged still means the term is IP-independent, which usually means a configuration contradiction. Not challenged means the term was IP-attached, which usually means pool reuse or reputation. This is a two-minute experiment and it resolves most cases; do it before touching the browser configuration.

Attack the dominant term, in order. From the trajectory output, the terms that actually matter are the largest ones. Sorting the live contributions and working down the list is faster than reasoning about the model. When two terms are close in size, the one that does not decay is the more urgent one, because the other will partly resolve itself.

Do not chase the last few points. A session sitting at 35.9 in an allow band that starts at 40 has 4.1 points of headroom, and the fastest way to lose it is a rate spike that adds a volumetric term. Adding an IP to a pool that is already at 20 per profile increases the reuse term for every profile in the pool. The marginal gain on the score is smaller than the marginal risk, so stop.

Watch for the reset you do not control. Third-party script changes, browser version updates, provider pool churn and per-IP TTL expiry all move terms you did not choose. So treat the observed band as a time series with an expected drift envelope, and alarm on the rate of change rather than on level — an alert that fires when a long-clean profile jumps 30 points in an hour is far more informative than one that fires at a static threshold. The SLO framing for that is in , and the recovery playbook it feeds is in .

The defensive mirror of all of this is the honest conclusion: a session profile is a long-lived asset whose score is dominated by structural properties that behavioural discipline cannot repair, and whose recovery is measured in days. Both sides of that are covered from the attacker's side in , which is the same model with the sign flipped.