Documentation

Policy

UA classification, rate limits, path triggers, robots.txt, the trigger families, and flag triggers.

Policy

On this page

mod_botshield decides which requests deserve friction by composing six policy families on top of the score-driven tier ladder. This page covers each family's directive, predicate shape, side effects, and where it sits in the runtime walk.

The runtime order (one pass per request, first short-circuit wins):

  1. Cookie triggers — pre-handler state. Cookie family accumulates pass-with-credit across multiple matches.
  2. Env triggers — predicate on Apache environment variables (SetEnvIfExpr, mod_rewrite [E=…]). First-match wins; gated on ap_is_initial_req so internal-redirect legs don't double-apply.
  3. Load triggers — predicate on the global load_state sampled by the load watchdog.
  4. Path triggers — predicate on the request URI.
  5. Block-path — cohort + path-glob → 403.
  6. Robots.txt Disallow — RFC 9309 matcher → 403.
  7. Rate-limit — cohort + budget → 429 with Retry-After.
  8. Robots.txt Crawl-delay — per-group rate cap.

The policy walk runs before the built-in heuristics, so a matching policy rule can short-circuit the request even when the client also has a valid _bs_session cookie. Allow-list checks and built-in heuristics (missing-UA, missing-Accept-Language, scraper-pattern UA) run after the policy walk if the walk returns OK; flagtrigger effects are applied last, against the IP's accumulated flag bitmap.

Allow list — verified crawlers

BotShieldAllowBot registers a UA pattern + IP-range pair that the allow-list classifier checks during the heuristics phase. Verified crawlers (UA matches AND IP is in the published range) get a hard pass — they bypass the score ladder entirely.

BotShieldAllowVerifiedBots on
BotShieldAllowBot googlebot "Googlebot/" /var/lib/botshield/bots/googlebot.txt
BotShieldAllowBot bingbot   "bingbot/"   /var/lib/botshield/bots/bingbot.txt
BotShieldAllowBot internal-monitor "MonitorBot/" 10.0.0.0/8,2001:db8::/32

The third arg is shape-inspected — no separate flag or sentinel:

  • starts with / → absolute path to a CIDR file (one CIDR per line, # comments, blank lines OK, IPv4 + IPv6).
  • contains / or : → comma-separated inline CIDRs.
  • * alone → UA-match only; no IP check. Logged with reason allow-bot-ua:<name> instead of allow-bot:<name>.
  • omitted → default file path /var/lib/botshield/bots/<name>.txt.

The CIDR file is read once at config-parse time (size cap 1 MiB) and cached on the per-server config. Refresh with a reload.

A built-in seed list covers Googlebot, Bingbot, Applebot, Yandex, DuckDuckBot, and a handful of others — all installed under /var/lib/botshield/bots/. The services/refresh/botshield-refresh.py script fetches each provider's published JSON and rewrites the CIDR files in place.

Verified vs fake

A request whose UA matches a registered bot pattern is one of three states:

  • verified — UA matches, IP is in the range. Hard pass with reason verified-<name>.
  • fake — UA matches, IP is NOT in the range. Strong penalty with reason fake-<name>. Fake-bot detection is one of the most reliable signals — bot operators love claiming Googlebot.
  • unverified — UA matches, classifier hit but ranges aren't loaded for this name. Logged with reason bot-unverified for visibility, no score effect.

Rate limits and block paths

A rule carrying rate= caps requests-per-window for everyone it matches. Hits return 429 with Retry-After and add 50 to the score. The conditions are the rule's own -- a UA substring, an IP spec, a path, anything a rule can say:

<BotShieldRule api-burst>
    BotShieldIPSpec     10.0.0.0/8,2001:db8::/48
    BotShieldRate       60 60
</BotShieldRule>
<BotShieldRule scrapers>
    BotShieldUserAgent  wget
    BotShieldRate       10 60
</BotShieldRule>
<BotShieldRule ai-bots>
    BotShieldUserAgent  @ai-train
    BotShieldRate       1 1
</BotShieldRule>

(BotShieldRateLimit, which said the same thing with fewer available conditions, was retired on 2026-09-09.)

Match keys (any of):

  • ua=<substring> or ua=@<botgroup> — UA gate; omit or set to * for any UA.
  • ipspec=<spec> — same shape as BotShieldAllowBot: a path to a CIDR file, comma-separated inline CIDRs, or omit / * for any IP.

Rate keys (required):

  • budget=<N> — requests allowed per window (fixed-window counter, atomic CAS-updated SHM slot).
  • per=<sec|min|hour> (also accepts s/m/h) — bare integer rejected.

Both axes can't be */omitted — that would rate-limit every request, rejected at config time. The legacy 5-arg positional form <name> <budget> <per> <ua-pattern> <ipspec> is still accepted.

For path-conditional 403s, use BotShieldRule with respond=403 plus optional ua= / ipspec= match keys (the former BotShieldBlockPath directive, retired):

<BotShieldRule legacy-admin>
    BotShieldPath         /wp-admin/*
    BotShieldRespond       403
</BotShieldRule>
<BotShieldRule aggressive-scraper>
    BotShieldPath         /
    BotShieldUserAgent    AhrefsBot
    BotShieldRespond       403
</BotShieldRule>

Match keys (any of):

  • ua=<substring> or ua=@<botgroup> — UA gate
  • ipspec=<spec> — same shape as BotShieldAllowBot (CIDR file, comma-separated inline CIDRs, or omit/* for "any IP")

Action keys (any of): respond=, redirect=, flag=, ttl=, score=, logas=, mode=enforce|observe. Convention is match keys first, action keys after — the parser doesn't enforce ordering but readability rewards consistency.

Repeated-429 escalation

BotShieldEscalate upgrades a rule that's already been firing — repeated 429s on the same IP escalate to 403 (or any configurable status):

BotShieldEscalate api-burst 5 min respond=403 ttl=3600

Args: <rule> <strikes> <per> [respond=N] [ttl=N]. <per> accepts sec/min/hour. If a rate-limiting rule refuses <strikes> requests within the window, the IP is upgraded to the configured status for ttl seconds (lives in the strike SHM table). The original rate-limit rule still runs; the escalation is a separate decision applied on top.

Path-pattern semantics

Path globs use a single * wildcard at the trailing edge. A non-trailing * (e.g. /api/*/v2/) emits a NOTICE at config-parse time — the v1 matcher would have treated the inner * as a literal byte; the current matcher follows RFC 9309's leftmost greedy semantics. The NOTICE warns that intent may have shifted; existing configs aren't broken, just verified.

Robots.txt enforcement

<BotShieldRobots> plugs in an RFC 9309 robots.txt policy: a parsed file, groups written in the config, or both. Disallow rules become robotsblock:<group> matches; Crawl-delay rules become per-crawler rate limits.

<BotShieldRobots>
    BotShieldRobotsTxt        /etc/botshield/robots.txt
    BotShieldRefreshInterval  60
    BotShieldWildcardScope    heuristic
</BotShieldRobots>

Inside the container:

  • BotShieldRobotsTxt <path> -- the file. A background watchdog re-parses it on mtime change; the inline groups are re-added to every build, so they survive a refresh.
  • BotShieldRefreshInterval <sec> -- how often the watchdog checks mtime. Default 60. Set to 0 to disable hot-reload.
  • BotShieldMode enforce|observe -- the default for every group; a group may override it.
  • BotShieldWildcardScope <mode> -- how strict the matcher is on User-agent: * rules:
    • heuristic (default): wildcard rules apply only to UAs that look like crawlers.
    • strict: wildcard rules apply to every UA.
    • off: ignore wildcard groups entirely. Only named-group rules apply.
  • <BotShieldRobotRule name> -- a group: BotShieldUserAgent (repeatable), BotShieldDisallow / BotShieldAllow, BotShieldCrawlDelay, and the knobs a file has no words for: BotShieldRespond, BotShieldMode, BotShieldLogAs.

The file and the groups are one document, so precedence is computed across both. See directives for the full vocabulary and the observe step-aside rule.

Group iteration is exposed by httpd -t -D DUMP_BOTSHIELD_POLICY for inspection (see observability).

Reading the effective policy

httpd -t -D DUMP_BOTSHIELD_POLICY also prints the tier thresholds and every flag trigger after reset processing, each with the source it came from:

## Flag triggers (effective, after reset)
# flag              action      value          mode      source
honeypot_hit       score       botsignals+60  enforce   configured
pow_fail_streak    tier_floor  interactive    enforce   configured

The source column is the point. "Not in the config file" and "not in effect" are different things, and two production lockouts came from confusing them: a flag was configured to score 50 against a noninteractive cut-point of 20 that was a compiled-in default and appeared nowhere an operator could read. The config said add=50 and nothing on the system said what 50 meant.

That cut-point does not exist any more, and neither do the other two. A number can only reach a tier through a BotShieldChallengeAtLeast row that names the accumulator it lands in, which is a line in the config rather than a constant in the binary. A flag still scoring onto the cumulative total reaches nothing at all, and the dump flags that with !! — it is the one configuration that looks like it does something and does not.

Two interactions are called out inline rather than left to documentation nobody consults at the moment it matters:

~ — a flag scoring at or above the noninteractive threshold. Such a flag is a challenge switch rather than a contributing signal. Bounded by the tier decision, which does not challenge a client at a level it has already passed, so the residual risk is a client that cannot solve.

!! — a tier_floor at or above interactive while that threshold is parked. The floor is MAX'd in after the score-to-tier decision and ignores thresholds entirely, so parking them does not contain it.

Triggers — predicate-action engine

Five trigger families share one config-time action engine and one request-time executor. Each family differs only in its predicate; they all funnel through the same bs_trigger_action struct and the same shared action keys.

Family Directive Predicate
Path BotShieldRule URI glob
Cookie BotShieldCookie / BotShieldCookies / BotShieldBSCookie inside a rule Cookie name + value, or the bulk shape
Env BotShieldEnv inside a rule Apache env var
Feedback BotShieldEvent inside a <BotShieldFeedback> App-signed event name (response path)
Load BotShieldLoadAvgAtLeast / BotShieldLatencyAtLeast / BotShieldBusyWorkersAtLeast / BotShieldFpmBusyAtLeast / BotShieldFpmQueueAtLeast / BotShieldDbRunningAtLeast inside a rule Per-CPU load average, Apache mean latency, or the work signals

Shared action keys

Every family parses <predicate-args> <action-key>=<value>.... The action keys are:

Key Effect
respond=<code> HTTP status to return. pass lets the request continue (cookie/env families accumulate; path family declines to real handler)
redirect=<url> Send an HTTP redirect with the chosen status (default 302)
logas=<tag> Stash a tag in r->notes for the access log (%{BS-…}n) and the decision-log line
flag=<name> Add a flag bit on the IP's flagged-IP entry (e.g. flag=honeypot_hit)
ttl=<sec> TTL on the flag-IP entry. Required when flag= is set
score="<name> +N" Move a named per-request accumulator. -N subtracts, =N assigns. Read by whichever BotShieldChallengeAtLeast rows name it
mode=observe Per-rule observe mode: predicate evaluates, side-effects suppressed. See staging

Path triggers

<BotShieldRule admin-honeypot>
    BotShieldPath         /admin/.env
    BotShieldRespond      403
    BotShieldFlagIP       honeypot_hit
    BotShieldLogAs        admin-trap
</BotShieldRule>
<BotShieldRule api-burst-trap>
    BotShieldPath         /api/*/burst
    BotShieldScore  botsignals +30
    BotShieldLogAs          api-burst
</BotShieldRule>

First-match wins (declaration order). On match, the path family's respond=nochallenge short-circuits to DECLINED (real handler runs); any other status is the response code.

There is no cookie trigger family. A cookie is a condition on a rule, so it composes with everything else the rule asks:

<BotShieldRule guest-session>
    BotShieldCookie    sessionid=guest
    BotShieldPath      /checkout
    BotShieldScore     botsignals +15
    BotShieldLogAs     guest-session
</BotShieldRule>

<BotShieldRule cookieless>
    BotShieldCookies   none
    BotShieldScore     botsignals +5
    BotShieldLogAs     cookieless
</BotShieldRule>

BotShieldCookie names one cookie: bare for presence, !name for absence, name=value for equality, name!value for present-but-not, name~substring for contains. BotShieldCookies is the bulk question — none, any, session — and BotShieldBSCookie asks about the module's own cookie, which has states (verified, missing, invalid) rather than a presence bit.

The family this replaced accumulated across matches and short-circuited on the first non-pass. A rule does neither: it matches or it does not, and it acts once. That difference is the point — the walk semantics were a second control flow to hold in your head, and the conditions were the part anybody wanted.

Environment conditions

Also a rule condition, and the reason this module has no regex of its own. Apache already ships regex engines that are tuned and audited; running an operator-supplied pattern against attacker-controlled input on every request would be a denial-of-service surface pointed the wrong way. So the pattern matching happens where it already lives, and the rule reads the result:

SetEnvIfExpr "%{HTTP:CF-Connecting-IP} =~ /:/" BS_IPV6=1
SetEnvIf User-Agent "(?i)\bcurl\b" BS_CLI=1

<BotShieldRule cli-on-checkout>
    BotShieldEnv       BS_CLI
    BotShieldPath      /checkout
    BotShieldScore     botsignals +10
    BotShieldLogAs     cli
</BotShieldRule>

BotShieldEnv takes NAME, !NAME and NAME=value. No contains: richer matching belongs in whatever set the variable, which is the whole arrangement.

RewriteRule ... [E=VAR:VAL] works as a producer too, and so does anything else that writes r->subprocess_env before the handler.

Feedback triggers

App emits a response header X-BotShield-Feedback: event=<name>;sig=<hmac>; the module verifies the HMAC and looks up the event name in the configured feedback-trigger table:

BotShieldAppFeedback                  on
BotShieldAppIntegrationSecretFile     /etc/botshield/app-integration-secret
<BotShieldFeedback scanner-hit>
    BotShieldEvent        scanner-hit
    BotShieldFlagSession  honeypot_hit
    BotShieldLogAs        app-trap
</BotShieldFeedback>
<BotShieldFeedback human-pass>
    BotShieldEvent        human-pass
    BotShieldFlagSession  app_verified_human
</BotShieldFeedback>

The event-name → action indirection is the security property: a compromised app can emit any event name, but only configured mappings reach module memory. Wire format details and signing are covered in captcha.

Feedback runs on the response path but its side effect is future-request state (the flagged-IP write). Both BotShieldEnabled LogOnly and per-trigger mode=observe apply — either gates the filter into logging feedbacktrigger:<event>: observe and skipping the SHM mutation. See staging.

Load conditions

A shed ladder is rules in declaration order with the strictest rung first. BotShieldLoadAvgAtLeast <N> matches the per-CPU 1-minute load average, 1.0 being one runnable process per core:

<BotShieldRule shed-hard>
    BotShieldLoadAvgAtLeast 4.0
    BotShieldUserAgent      @bot
    BotShieldRespond        503
</BotShieldRule>

A BotShieldMinLoad condition matching the three-state machine existed until 2026-09-07 and was removed; the state is still sampled and reported as the load_state gauge, but no rule reads it. On a host where MaxRequestWorkers bears no relation to what the hardware can serve — see directives — the ratio is the one to distrust.

Do not read that as "the load average is the one that moves during an outage". Measured on 2026-09-01, the two disagreed sharply: the load state reached warm 172 times that day and the shed rules recorded 3709 observe hits, while 98.3% of those hits fell in ten-minute windows whose per-CPU load average was between 0.07 and 0.24. The busiest shedding window of the day, 53% of the hits on its own, had the lowest load reading in the set. Whichever of the two was right, they are not measuring the same thing, and neither has been validated against a confirmed outage on this host. That is the gap BotShieldLatencyAtLeast exists to close.

BotShieldLatencyAtLeast <ms> is the third, and asks the question the other two are structurally unable to answer:

<BotShieldRule shed-when-slow>
    BotShieldLatencyAtLeast 1000
    BotShieldUserAgent      @bot
    BotShieldRespond        503
</BotShieldRule>

A worker waiting on a database socket sits in interruptible sleep and does not count toward the load average, so a server whose every worker is stuck on the database reads as idle to a loadavg rule while taking thirty seconds to answer. Mean request duration is the one number of the three that rises in that situation. It needs ExtendedStatus On, and declines while the metric is unavailable rather than treating "cannot measure" as "very slow".

That reasoning held, and the measurement did not. Request duration runs until the last byte is sent, so a slow client downloading a large file reads as a slow server while costing almost nothing, and the mean over a sample is dominated by its slowest single request. Measured on 2026-09-15: 91% of the samples that crossed 500ms were one request supplying 80% or more of that sample's time, and 61% contained only one request. On ordinary days the signal read hot for 26-41 minutes, which is not what an ordinary day looks like.

Four conditions measure the work instead, and none of them moves for a download:

<BotShieldRule shed-bots-when-php-is-full>
    BotShieldFpmBusyAtLeast  80
    BotShieldUserAgent       @bot
    BotShieldRespond         503
</BotShieldRule>
Directive Reads
BotShieldBusyWorkersAtLeast <n> Apache worker slots busy
BotShieldFpmBusyAtLeast <pct> PHP-FPM active processes, percent of pm.max_children
BotShieldFpmQueueAtLeast <n> requests waiting for a PHP-FPM worker
BotShieldDbRunningAtLeast <n> database threads running

PHP-FPM answers the database-stall case the load average cannot: a worker blocked on the database is a PHP-FPM process that is busy and not finishing, and the queue behind it grows. A sample older than 60 seconds is treated as no reading and the rule declines, for the same reason the latency condition declines when unavailable.

Replayed against this deployment's outages, busy workers separated broken from ordinary cleanly: Aug 20 and Aug 27 ran near 950 requests in flight against an ordinary-day peak of 119-181. Set that threshold well below MaxRequestWorkers — at the ceiling, connections wait in the accept queue and never reach the module to be shed.

Whatever the condition, a load-conditioned rule that answers with anything but BotShieldNoChallenge is counted as shedding, and shows on the dashboard as Requests shed — or Would shed while the rule is in observe, which is how a threshold gets chosen before anyone is turned away.

All three fire at or above, so a ladder is rules in declaration order with the strictest rung first, and all read the sample the watchdog last published: for one refresh interval after a config reload that sample is 0 and neither fires.

This was BotShieldLoadTrigger, a family of its own, until the predicate moved into the rule. The family could match load and nothing else; shedding is nearly always "this kind of client at this load", which needs the other conditions alongside it.

The family also had exact state=<level> beside state>=. Nothing carried it over and nothing wanted it: the only exact form ever written was state=hot, which means the same as state>=hot because hot is the top of the scale. "Warm but not hot" has never been asked for.

On the three levels. normal|warm|hot come from the busy-worker ratio with hysteresis, and shm.h is blunt that this signal is weak on a large worker pool — 1024 slots on 6 cores means a fully unusable site still reads 2-3% busy. The module already samples load average, database saturation, FPM saturation and mean request latency into SHM for the dashboard; none of them is reachable from a rule yet. Until they are, BotShieldLoadStateFile is the escape hatch: an external process that can see the real signal writes warm or hot into it, and the watchdog merges it most-severe-wins with what it measured.

No compiled-in defaults

The module seeds nothing at config-parse time. A fresh install scores nothing and challenges nothing until you declare both a flag trigger and a tier threshold — the module used to seed a sensible-looking slate automatically, and that was removed: a default every deployment has to disable was not a default, and every lockout this module has caused traced back to an implicit weight nobody had written down.

The slate it used to seed is kept as a documented starting point rather than deleted outright — the flag-triggers example and its heuristic-trigger counterpart, with the same values these paired score + tier_floor rows used to carry:

Flag Starter action
honeypot_hit score add=+60, tier_floor min=captcha
fake_bot score add=+80, tier_floor min=captcha
scanner_probe score add=+50, tier_floor min=interactive
pow_fail_streak score add=+30, tier_floor min=noninteractive
app_verified_human score add=-80
app_verified_session score add=-40
app_trust_signal score add=-20

rate_abuse and robots_ignored are absent from that table on purpose. They are the only flags the module writes on its own, and a starter action for them would be a policy nobody asked for applied to every client who ever overspent a budget. What they should mean is a rule you write; see directives.

Trust signals (credits) are score-only by design; no credit ever forces tier down. A verified-human flag can't unlock a request that already tripped a different tier_floor.

None of this is active unless you paste it into your own config. Written as rules, a second rule for the same flag adds to the first — scores sum, challenge floors MAX — because a rule carrying only a score or a challenge continues the walk. To replace rather than accumulate, declare the one rule you want: there is no reset, because declaration order says it.

Rules in an Apache container

A <BotShieldRule> can be declared inside <Location>, <Directory>, <Files>, their regex forms, and <If> — the same places Require and Header work. Declared there it needs no match key: Apache has already matched, and the container is the condition.

<Location "/admin/.env">
    <BotShieldRule admin-trap>
        BotShieldFlagIP       honeypot_hit
        BotShieldLogAs        admin-trap
    </BotShieldRule>
</Location>

<LocationMatch "(?i)/wp-(login|admin)">
    <BotShieldRule wp-trap>
        BotShieldFlagIP       scanner_probe
        BotShieldScore        botsignals +20
        BotShieldLogAs        wp-trap
    </BotShieldRule>
</LocationMatch>

<Files "*.php">
    <If "%{REQUEST_URI} =~ m#/uploads/#">
        <BotShieldRule php-in-uploads>
            BotShieldRespond      403
            BotShieldLogAs        php-in-uploads
        </BotShieldRule>
    </If>
</Files>

This is the recommended way to express anything <Location>-shaped, and it is worth reaching for over BotShieldPath when the shape you want is one Apache can already match — <LocationMatch> takes a regex, <Directory> matches the filesystem, <If> takes an expression, and BotShieldPath is a glob with a trailing $ anchor.

Everything else about the rule is unchanged: conditions still apply and AND with the container, mode=observe still stages it, and the actions are the same.

This was a family of its own, BotShieldTrigger, until 2026-09-06. Its predicate was the container match and it had no conditions, which is a rule whose condition Apache has already evaluated — so it could say nothing a rule could not, and cost an operator a second directive to learn.

Ordering, and opting out

Two ladders are walked: rules declared in a container first, then the server-scope ones. Within the container ladder the innermost scope comes first.

Both are the same rule, applied twice: under first-match-wins the more specific statement has to get the first word. Otherwise a broad vhost rule would answer for a path someone wrote a <Location> about, and a rule in <Location /a> would answer for a request that <Location /a/b> exists to handle.

That ordering is also how a nested scope opts out of what an outer one does — write a rule that matches and passes:

<Location "/api">
    <BotShieldRule api-tax>
        BotShieldScore        botsignals +10
        BotShieldLogAs        api-tax
    </BotShieldRule>
</Location>

<Location "/api/health">
    <BotShieldRule health-exempt>
        BotShieldNoChallenge
        BotShieldLogAs        health-exempt
    </BotShieldRule>
</Location>

BotShieldTrigger had a reset keyword for this. A rule that says what it wants is one mechanism rather than two, and it is visible in the config instead of in a merge rule.

nochallenge waives the challenge, not the policy

respond=nochallenge means "do not put an interstitial in front of this request". It does not mean "exempt this request from everything". Rate limits and robots.txt Disallow still apply to a request that matched such a rule.

That distinction used to not exist. A bare pass returned out of the policy walk immediately, which skipped robots.txt, the cohort rate limits and the per-slug bot rate limit along with the challenge. A rule written to let declared crawlers read content without an interstitial was therefore also making them unratelimitable across most of the site, and nothing in the config said so.

The rule still shadows later rules on the same path: this family is first-match-wins and a nochallenge declared first stops the walk, exactly as before. What changed is only that the walk continues into the enforcement stages rather than returning.

pass is no longer accepted, and a config still using it fails configtest rather than reloading with a changed meaning. The word was ambiguous in practice and not just in principle: the decision log emitted tier=pass on 14,066 lines of a single day on one deployment, meaning "no challenge was served", so an operator grepping for the rule spelling and the outcome spelling got each other's matches.

nochallenge is now the only spelling, on every surface. It says what happens rather than what the module declined to do, and it carries no dash, so it survives being split out of a reason token.

Safeguard

The safeguard suppresses a challenge loop: a client that has been issued challenges repeatedly within the safeguard window without ever returning a verified cookie gets a 302 redirect (tier=safeguard outcome=redirect) to a configured BotShieldRedirectURL or to the built-in explainer at <BotShieldEndpointPrefix>/safeguard-info. The original URI is appended as ?return=<urlencoded path>. The per-IP counter clears on redirect so a fresh failure cycle starts after the client engages with the redirect target.

<BotShieldSafeguard>
    BotShieldThreshold    5
    BotShieldWindow       600
    BotShieldTTL          900
    # Optional. When unset, the redirect points at
    # /botshield/safeguard-info (the module's built-in explainer).
    BotShieldRedirectURL  /help/auto-check-failed
</BotShieldSafeguard>

Those are the defaults, so the block is only needed to change them — or to turn safeguard off with BotShieldEnabled Off inside it.

Defaults: 5 missed verifications in 600 seconds, and the safeguard state lasts 900 seconds after the last presentation. It grants no pass window: the tripped client is redirected to the explainer, not admitted, so failing on purpose buys a bot nothing. The IP's flagged-IP entry is preserved so the suspicious behavior still feeds downstream signals.

Sites staging a fresh deployment with aggressive thresholds are the most likely to trip this. Watch the tier_nochallenge_total counter for an unusual climb under "safeguard" reasons in the decision log (safeguard rolls into pass for metric binning; the decision log reason challengesafeguard is the filter).

Where to next