Documentation
Policy
UA classification, rate limits, path triggers, robots.txt, the trigger families, and flag triggers.
Policy
On this page
- Allow list — verified crawlers
- Verified vs fake
- Rate limits and block paths
- Repeated-429 escalation
- Path-pattern semantics
- Robots.txt enforcement
- Reading the effective policy
- Triggers — predicate-action engine
- Shared action keys
- Path triggers
- Cookie conditions
- Environment conditions
- Feedback triggers
- Load conditions
- No compiled-in defaults
- Rules in an Apache container
- `nochallenge` waives the challenge, not the policy
- Safeguard
- Where to next
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):
- Cookie triggers — pre-handler state. Cookie family accumulates pass-with-credit across multiple matches.
- Env triggers — predicate on Apache environment
variables (
SetEnvIfExpr, mod_rewrite[E=…]). First-match wins; gated onap_is_initial_reqso internal-redirect legs don't double-apply. - Load triggers — predicate on the global load_state sampled by the load watchdog.
- Path triggers — predicate on the request URI.
- Block-path — cohort + path-glob → 403.
- Robots.txt Disallow — RFC 9309 matcher → 403.
- Rate-limit — cohort + budget → 429 with Retry-After.
- 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 reasonallow-bot-ua:<name>instead ofallow-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-unverifiedfor 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>orua=@<botgroup>— UA gate; omit or set to*for any UA.ipspec=<spec>— same shape asBotShieldAllowBot: 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 acceptss/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>orua=@<botgroup>— UA gateipspec=<spec>— same shape asBotShieldAllowBot(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 onUser-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.
Cookie conditions
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
- Captcha and app-bridge protocols: captcha.
- Safe rule rollout: staging.
- Metrics and dashboards: observability.
- Full directive reference: directives.