Agentic Bot Detection at Kubernetes Ingress: Envoy ext_authz Scoring for LLM-Driven Traffic
The Problem
Every classical bot detection system is built on the assumption that bots are distinguishable from humans because they automate things badly. Headless browsers skip JavaScript execution. Selenium leaves DOM artifacts. Curl forgets to copy Accept-Language headers. Automated tools produce inhuman timing — either unrealistically fast or unrealistically regular. CAPTCHA challenges exploit this gap: a human can read distorted text or identify traffic lights in a grid; a script cannot.
OpenAI Operator, Claude Computer Use, Microsoft Copilot with browser tools, and open-source Playwright-plus-LLM frameworks eliminate that gap almost entirely. These systems use real browser engines — Chromium, Firefox — driven by an LLM that reasons about the page, decides what to do, and issues the input events a human would. They run JavaScript. They render pages. They move the cursor along Bézier-curve paths that match human motor variability. They type with inter-keystroke delays that sample from realistic distributions. They use the browser’s own TLS stack, so JA4 fingerprints match real Chrome. They solve CAPTCHAs using AI vision models and audio fallback solvers.
The result is that per-request and per-interaction signals — the signals that DataDome, PerimeterX, Akamai Bot Manager, and every in-house WAF rule are built on — no longer reliably distinguish agent traffic from human traffic. An Operator agent loading your login page looks, to every client-facing probe, like a person opening a new Chrome tab.
What the agent cannot fake is the session-level structure of its behaviour. A human browsing your site follows the natural information-scent of the page graph: they click links that interest them, navigate back, open tangents, dwell on content they are reading, and have long idle gaps when they get distracted. Their session has high navigational entropy. An LLM agent follows its task graph, not your page graph. It navigates from its entry point directly to its objective — login → account settings → export data → done — with low navigational entropy, statistically regularised inter-request timing, and no back-navigation unless the task requires it. HTTP/2 stream multiplexing patterns also differ: a human browser fetches the HTML for a page and then issues many parallel sub-resource requests for scripts, stylesheets, and images. An agent driven by a tool-use loop often fetches resources sequentially, issuing the next request only after processing the previous response.
These session-level signals are accessible at the Kubernetes ingress layer before any request reaches an application pod. Envoy’s ext_authz filter is the correct interception point: it sees every request, has access to all request headers, can consult an external service synchronously, and can modify or reject the request before upstream forwarding. The architecture in this article places a multi-signal scoring service behind ext_authz, maintains per-session state in Redis, scores each request against the accumulated session history, and returns a score and action decision as enriched headers that downstream application pods can act on.
This is not a complete solution — novel agent frameworks will require retraining scoring models, and a well-resourced attacker can deliberately degrade session coherence to avoid detection — but it raises the cost of automated agent abuse against Kubernetes-hosted services materially above the current baseline of no defence.
Threat Model
The attack surface is any Kubernetes-hosted service with value that scales with access volume: e-commerce pricing and inventory, paywalled content, authenticated account operations, form-submission endpoints that create records, and checkout flows.
Automated account takeover via agentic browsing. An Operator-style agent navigates the login flow, handles 2FA UI prompts by reading TOTP codes from an email account the attacker also controls, and changes security settings to establish persistent access. Classical ATO detection looks for credential stuffing velocity — hundreds of failed logins from one IP. The agent logs in successfully on the first attempt and proceeds slowly. Velocity rules do not fire.
Content scraping at scale. A pool of Claude Computer Use agents, each running a real browser session with a residential proxy, systematically reads every product description, price, article, or data record accessible from an authenticated or unauthenticated session. Each agent session is indistinguishable from a real customer on a per-request basis. The aggregate across a pool of twenty agents running in parallel extracts the entire content graph within hours.
Automated fraud through complete workflow execution. An agent fills registration forms with synthetically generated but structurally valid identity data, completes checkout flows using stolen payment credentials, or submits forms that trigger business processes (refund requests, dispute claims, promotional code redemptions). The agent handles every page interaction a human would, including required confirmation dialogs, email verification links, and review steps.
Competitive intelligence extraction. An agentic bot systematically navigates every authenticated page of a SaaS product to extract proprietary feature sets, pricing tiers, customer segmentation, or internal tooling details. The session looks like an unusually thorough user.
Standard defences fail against all four. IP reputation fails because agents use clean residential proxies. CAPTCHA fails because AI vision models solve standard CAPTCHA types with high reliability. JavaScript challenge fails because Chromium executes it. Behavioural scoring of mouse movement and typing fails because LLM-generated patterns are human-plausible at the per-interaction level.
Hardening Configuration
1. Envoy ext_authz Filter Configuration
The ext_authz filter intercepts every HTTP request before it reaches the upstream cluster. For agentic bot scoring, apply it at the ingress gateway scope so all public routes are covered before any route-specific logic runs.
# EnvoyFilter applied at the Istio ingress gateway scope.
# Istio 1.25+ / Envoy Gateway 1.4+ / Kubernetes 1.30+
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: bot-scoring-ext-authz
namespace: istio-system
spec:
workloadSelector:
labels:
app: istio-ingressgateway
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
grpc_service:
envoy_grpc:
cluster_name: outbound|50051||bot-scoring-service.bot-defence.svc.cluster.local
timeout: 50ms
failure_mode_allow: true # Fail open — scoring failure must not block traffic
with_request_body:
max_request_bytes: 8192
allow_partial_message: true
include_peer_certificate: false # TLS fingerprint extracted separately (see §3)
# Strip any client-supplied scoring headers before the scorer sees them.
# An agent that injects x-bot-decision: allow would otherwise bypass all downstream checks.
clear_route_cache: false
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
patch:
operation: MERGE
value:
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
request_headers_to_add:
- header: { key: "x-ja4", value: "%DOWNSTREAM_PEER_JA4%" }
- header: { key: "x-ja4h", value: "%DOWNSTREAM_PEER_JA4H%" }
- header: { key: "x-envoy-alpn", value: "%DOWNSTREAM_PEER_ALPN%" }
request_headers_to_remove:
- x-bot-score
- x-bot-decision
- x-bot-challenge
The failure_mode_allow: true setting is the correct default for a scoring service that enriches requests rather than enforcing hard authentication. A bot scorer adds signal; its unavailability should not take down production traffic. If the scorer is down, requests proceed without bot score headers — downstream applications treat missing headers as “no signal” and apply their default policy. The 50ms timeout is deliberate: a scoring service that takes longer than 50ms is either overloaded or unhealthy and should be treated as unavailable.
JA4 and JA4H fingerprints are injected as headers by the Envoy connection manager before the ext_authz filter runs. Stripping any client-supplied x-bot-* headers is mandatory — without it, an agent that has observed your header names can inject x-bot-decision: allow and skip all downstream challenge routing.
2. Bot Scoring Service — Multi-Signal gRPC Implementation
The scoring service is a gRPC server implementing the Envoy envoy.service.auth.v3.Authorization interface. It maintains per-session state in Redis with a 30-minute TTL and scores each request against four signals.
# bot_scorer/service.py
import statistics
import time
import hashlib
import redis
import json
from concurrent import futures
import grpc
from envoy.service.auth.v3 import external_auth_pb2 as auth_pb
from envoy.service.auth.v3 import external_auth_pb2_grpc
import networkx as nx
REDIS = redis.Redis.from_url("redis://redis-bot-scorer.bot-defence:6379/0")
SESSION_TTL = 1800 # 30 minutes
def extract_session_id(request: auth_pb.CheckRequest) -> str:
headers = {k.lower(): v for k, v in request.attributes.request.http.headers.items()}
# Use the application session cookie if present; fall back to IP+UA hash.
cookie = headers.get("cookie", "")
for part in cookie.split(";"):
p = part.strip()
if p.startswith("sid="):
return "sess:" + p[4:]
ua = headers.get("user-agent", "")
ip = request.attributes.source.address.socket_address.address
return "fp:" + hashlib.sha256(f"{ip}:{ua}".encode()).hexdigest()[:16]
def normalise_url(path: str) -> str:
# Collapse numeric IDs and UUIDs so /product/1234 and /product/5678 map to the same node.
import re
path = re.sub(r"/\d{4,}", "/{id}", path)
path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "/{uuid}", path)
return path
def build_request_graph(url_sequence: list) -> "nx.DiGraph":
G = nx.DiGraph()
for i in range(len(url_sequence) - 1):
src = normalise_url(url_sequence[i])
dst = normalise_url(url_sequence[i + 1])
if G.has_edge(src, dst):
G[src][dst]["weight"] += 1
else:
G.add_edge(src, dst, weight=1)
return G
def graph_entropy_score(url_sequence: list) -> float:
"""
Agents follow linear task graphs — few branches, direct convergence toward goal.
Humans follow branchy exploration graphs — back-navigation, tangents, dead ends.
Returns 0.0 (human-like) to 1.0 (agent-like).
"""
if len(url_sequence) < 4:
return 0.0
G = build_request_graph(url_sequence)
nodes = G.number_of_nodes()
edges = G.number_of_edges()
if nodes < 2:
return 0.0
# Count back-edges (navigation reversals) as a fraction of total edges.
back_edge_count = sum(1 for src, dst in G.edges() if G.has_edge(dst, src))
back_edge_ratio = back_edge_count / max(edges, 1)
# Branching factor: edges per node. Low = linear path = agent-like.
branching_factor = edges / nodes
# Combine: low branching + low back-edge ratio = high agent score.
linearity = max(0.0, 1.0 - (branching_factor - 1.0) * 0.5)
linearity = min(1.0, linearity)
no_reversal = 1.0 - min(1.0, back_edge_ratio * 4.0)
return (linearity * 0.6) + (no_reversal * 0.4)
def h2_stream_pattern_score(resource_fetch_gaps: list) -> float:
"""
Human browsers fetch sub-resources in parallel bursts after each HTML fetch.
Agents in tool-use loops often fetch sequentially: one resource, process, next.
resource_fetch_gaps: list of milliseconds between consecutive resource fetches.
A burst of parallel fetches produces near-zero gaps. Sequential fetches produce
gaps of >50ms between each resource.
Returns 0.0 (burst/human-like) to 1.0 (sequential/agent-like).
"""
if len(resource_fetch_gaps) < 4:
return 0.0
# Parallel fetches cluster near 0ms. Sequential fetches are spread across 50-500ms.
near_zero = sum(1 for g in resource_fetch_gaps if g < 20)
sequential = sum(1 for g in resource_fetch_gaps if g > 50)
if near_zero + sequential == 0:
return 0.0
sequential_ratio = sequential / (near_zero + sequential)
return min(1.0, sequential_ratio)
def goal_convergence_score(url_sequence: list, path: str) -> float:
"""
Human sessions wander. Agent sessions converge monotonically toward a goal.
Proxy: measure how many of the last N URLs are in the same section of the site
(same first-level path prefix). A human visiting /product/x then /blog/y then
/cart is navigating broadly. An agent visiting /account /account/settings
/account/security /account/security/2fa is converging.
"""
if len(url_sequence) < 5:
return 0.0
recent = [normalise_url(u) for u in url_sequence[-8:]]
# Extract first path segment.
prefixes = [u.split("/")[1] if "/" in u else u for u in recent]
unique_prefixes = len(set(p for p in prefixes if p))
if unique_prefixes == 0:
return 0.0
# Low unique_prefixes / total = high convergence = agent-like.
convergence = 1.0 - (unique_prefixes / len(prefixes))
return min(1.0, convergence * 1.5)
def timing_regularity_score(timestamps: list) -> float:
"""
Even LLM-generated timing is more regular than real human timing.
Humans have long tail events: they stop to think, get distracted, come back.
Agents sample from a tight distribution.
Coefficient of variation (CV = stddev/mean) of inter-request intervals:
- Humans: CV typically 0.8 - 2.5 (high variance, fat tail)
- Agents: CV typically 0.1 - 0.5 (tight distribution)
Returns 0.0 (human-like) to 1.0 (agent-like).
"""
if len(timestamps) < 6:
return 0.0
intervals = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)]
# Filter out very long gaps (>300s) that represent session suspension, not agent timing.
active_intervals = [iv for iv in intervals if iv < 300.0]
if len(active_intervals) < 4:
return 0.0
mean_iv = statistics.mean(active_intervals)
if mean_iv < 0.05:
# Sub-50ms gaps are resource fetches, not navigational timing — skip.
return 0.0
stdev_iv = statistics.stdev(active_intervals)
cv = stdev_iv / mean_iv
# CV < 0.4 = very regular = strongly agent-like → score 1.0
# CV > 1.5 = highly variable = human-like → score 0.0
if cv >= 1.5:
return 0.0
return min(1.0, (1.5 - cv) / 1.1)
class BotScoringService(external_auth_pb2_grpc.AuthorizationServicer):
def Check(self, request: auth_pb.CheckRequest, context):
headers = {k.lower(): v for k, v in request.attributes.request.http.headers.items()}
path = request.attributes.request.http.path
now = time.time()
session_id = extract_session_id(request)
# Load session state from Redis.
raw = REDIS.get(session_id)
if raw:
state = json.loads(raw)
else:
state = {
"url_history": [],
"timestamps": [],
"resource_gaps": [],
"first_seen": now,
}
url_history = state["url_history"]
timestamps = state["timestamps"]
# Update session state before scoring (so this request is included).
if timestamps:
gap_ms = (now - timestamps[-1]) * 1000.0
# Classify as resource fetch if Content-Type is a sub-resource and gap is small.
if gap_ms < 500 and any(
ext in path for ext in [".js", ".css", ".png", ".woff", ".svg", ".ico"]
):
state["resource_gaps"].append(gap_ms)
url_history.append(path)
timestamps.append(now)
# Cap history length to bound Redis memory per session.
state["url_history"] = url_history[-50:]
state["timestamps"] = timestamps[-50:]
state["resource_gaps"] = state["resource_gaps"][-30:]
# --- Signal 1: Request graph entropy ---
s1 = graph_entropy_score(state["url_history"])
# --- Signal 2: H2 stream pattern (sequential vs parallel resource loading) ---
s2 = h2_stream_pattern_score(state["resource_gaps"])
# --- Signal 3: Goal convergence ---
s3 = goal_convergence_score(state["url_history"], path)
# --- Signal 4: Inter-request timing regularity ---
s4 = timing_regularity_score(state["timestamps"])
# Weighted combination. Timing and graph entropy are more reliable on long sessions.
# Goal convergence fires earlier but is noisier.
n = len(timestamps)
weight_timing = 0.35 if n >= 8 else 0.15
weight_graph = 0.30 if n >= 6 else 0.10
weight_goal = 0.20
weight_h2 = 0.15 if n >= 8 else 0.05
# Renormalise weights.
total_w = weight_timing + weight_graph + weight_goal + weight_h2
score = (
(s1 * weight_graph +
s2 * weight_h2 +
s3 * weight_goal +
s4 * weight_timing) / total_w
)
# Hard rule: explicit agent UA markers are a definitive signal.
ua = headers.get("user-agent", "").lower()
hard_match_reasons = []
if "anthropic-computer-use" in ua:
score = max(score, 0.95)
hard_match_reasons.append("computer-use-ua")
if "openai-operator" in ua or ("openai" in ua and "operator" in ua):
score = max(score, 0.95)
hard_match_reasons.append("operator-ua")
# Mariner / Copilot Browser sets a distinct product token.
if "copilot-browser" in ua or "ms-copilot-agent" in ua:
score = max(score, 0.90)
hard_match_reasons.append("copilot-browser-ua")
# Determine action.
if score >= 0.75:
action = "deny"
elif score >= 0.45:
action = "challenge"
else:
action = "allow"
signals = f"g={s1:.2f},h={s2:.2f},c={s3:.2f},t={s4:.2f}"
reasons_str = ",".join(hard_match_reasons) if hard_match_reasons else signals
# Persist updated state.
REDIS.setex(session_id, SESSION_TTL, json.dumps(state))
if action == "deny":
return auth_pb.CheckResponse(
status={"code": 16}, # UNAUTHENTICATED maps to HTTP 429 in ext_authz
denied_response=auth_pb.DeniedHttpResponse(
status={"code": 429},
headers=[
{"header": {"key": "x-bot-score", "value": f"{score:.3f}"}},
{"header": {"key": "x-bot-decision", "value": "deny"}},
{"header": {"key": "retry-after", "value": "60"}},
{"header": {"key": "content-type", "value": "application/json"}},
],
body='{"error":"rate_limited","code":429}',
),
)
add_headers = [
{"header": {"key": "x-bot-score", "value": f"{score:.3f}"}},
{"header": {"key": "x-bot-decision", "value": action}},
{"header": {"key": "x-bot-signals", "value": reasons_str}},
]
if action == "challenge":
add_headers.append(
{"header": {"key": "x-bot-challenge", "value": "javascript-pow"}}
)
return auth_pb.CheckResponse(
status={"code": 0},
ok_response=auth_pb.OkHttpResponse(headers_to_add=add_headers),
)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
external_auth_pb2_grpc.add_AuthorizationServicer_to_server(BotScoringService(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()
The session state structure is flat JSON rather than a proper feature store because the scoring logic operates on short windows (50 requests maximum). The weight scheme is intentionally simple and tunable via environment variables in a production deployment. The statistics.stdev call on the timing signal requires at least two data points; the guard len(timestamps) < 6 ensures it only fires when there is enough history for the CV calculation to be meaningful.
3. Bot Scoring Service Deployment and Redis
# Namespace for the bot-defence control plane.
apiVersion: v1
kind: Namespace
metadata:
name: bot-defence
labels:
pod-security.kubernetes.io/enforce: restricted
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bot-scoring-service
namespace: bot-defence
spec:
replicas: 3
selector:
matchLabels:
app: bot-scoring-service
template:
metadata:
labels:
app: bot-scoring-service
spec:
serviceAccountName: bot-scoring-sa
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: scorer
image: registry.example.com/bot-scorer:1.2.0
ports:
- containerPort: 50051
protocol: TCP
env:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: bot-scorer-secrets
key: redis-url
- name: SCORE_THRESHOLD_DENY
value: "0.75"
- name: SCORE_THRESHOLD_CHALLENGE
value: "0.45"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
readinessProbe:
grpc:
port: 50051
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
---
apiVersion: v1
kind: Service
metadata:
name: bot-scoring-service
namespace: bot-defence
spec:
selector:
app: bot-scoring-service
ports:
- port: 50051
targetPort: 50051
protocol: TCP
Redis is a single-point dependency for session state. Use Redis Sentinel or a managed Redis cluster with connection pooling. If Redis is unavailable, the scorer should log the failure and return an allow response with no scoring headers — missing headers downstream means “no signal,” not “blocked.”
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-bot-scorer
namespace: bot-defence
spec:
replicas: 1
selector:
matchLabels:
app: redis-bot-scorer
template:
metadata:
labels:
app: redis-bot-scorer
spec:
containers:
- name: redis
image: redis:7.2-alpine
command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
ports:
- containerPort: 6379
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: 200m
memory: 512Mi
The allkeys-lru eviction policy means Redis evicts the least-recently-used session states when memory pressure occurs. This is the correct behaviour: under load, old inactive sessions are discarded in preference to active ones.
4. NetworkPolicy Isolating the Bot Scoring Control Plane
The scoring service must only be reachable from the ingress gateway. It has access to session state for all users; a compromised pod with network access to the scorer could poison session state or extract session correlation data.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: bot-scorer-isolation
namespace: bot-defence
spec:
podSelector:
matchLabels:
app: bot-scoring-service
policyTypes:
- Ingress
- Egress
ingress:
# Accept gRPC only from the Istio ingress gateway pods.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: istio-system
podSelector:
matchLabels:
app: istio-ingressgateway
ports:
- port: 50051
protocol: TCP
egress:
# Allow outbound to Redis within the same namespace.
- to:
- podSelector:
matchLabels:
app: redis-bot-scorer
ports:
- port: 6379
protocol: TCP
# Allow DNS resolution.
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: redis-bot-scorer-isolation
namespace: bot-defence
spec:
podSelector:
matchLabels:
app: redis-bot-scorer
policyTypes:
- Ingress
ingress:
# Accept connections only from the scoring service.
- from:
- podSelector:
matchLabels:
app: bot-scoring-service
ports:
- port: 6379
protocol: TCP
This policy explicitly blocks all other ingress to both components. The bot-scoring service has no reason to make outbound connections other than to Redis, and Redis has no reason to accept connections from anything other than the scorer.
5. Progressive Challenge Routing
The scorer returns one of three decisions in the x-bot-decision header: allow, challenge, or deny. Routing at the Gateway API layer acts on these decisions before requests reach application pods.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: scored-traffic-routing
namespace: default
spec:
parentRefs:
- name: public-gateway
namespace: istio-system
rules:
# High-confidence agent traffic: return 429 immediately.
# (The ext_authz service returns a deny response directly for score >= 0.75,
# so this rule handles the case where the scorer is bypassed or unavailable
# and the application layer must act on an x-bot-decision: deny header.)
- matches:
- headers:
- name: x-bot-decision
value: deny
filters:
- type: RequestMirror
requestMirror:
backendRef:
name: bot-audit-sink
port: 8080
backendRefs:
- name: rate-limited-stub
port: 8080
# Medium-confidence: allow the request through but inject a challenge signal.
# The application reads x-bot-challenge and decides whether to present a PoW challenge.
- matches:
- headers:
- name: x-bot-decision
value: challenge
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
add:
- name: x-bot-challenge
value: javascript-pow
backendRefs:
- name: app-service
port: 80
# Low-confidence or no score: forward normally.
- backendRefs:
- name: app-service
port: 80
The application layer reads x-bot-challenge: javascript-pow and responds with a proof-of-work challenge page instead of the requested resource. When the client solves the PoW, the application issues a short-lived clearance cookie that is validated on subsequent requests. Agents can be programmed to solve PoW challenges, so this is a speed-bump rather than a hard block — the purpose is to add latency cost per challenge, making large-scale agent operation economically less attractive.
The deny case mirrors traffic to a bot-audit-sink service that logs the full request for operator review before returning 429. This provides a feedback signal for threshold tuning without relying solely on access logs.
6. Challenge Escalation Logic in the Scorer
The action decision in the scorer is a function of score and session length. Short sessions are scored conservatively because there is insufficient signal to make a reliable determination. The escalation thresholds are parameterised via environment variables rather than hardcoded.
import os
THRESHOLD_DENY = float(os.getenv("SCORE_THRESHOLD_DENY", "0.75"))
THRESHOLD_CHALLENGE = float(os.getenv("SCORE_THRESHOLD_CHALLENGE", "0.45"))
MIN_REQUESTS_DENY = int(os.getenv("MIN_REQUESTS_DENY", "8"))
def get_action(score: float, session_request_count: int) -> dict:
"""
Progressive escalation:
- < THRESHOLD_CHALLENGE or session too short: allow, log score.
- >= THRESHOLD_CHALLENGE and < THRESHOLD_DENY: allow with challenge header.
The application layer presents a proof-of-work challenge.
- >= THRESHOLD_DENY and session has enough history: deny at ext_authz level.
This is a hard block returned to Envoy, not a forwarded request.
"""
if score >= THRESHOLD_DENY and session_request_count >= MIN_REQUESTS_DENY:
return {
"action": "deny",
"status": 429,
"reason": "bot-score-exceeded",
}
elif score >= THRESHOLD_CHALLENGE:
return {
"action": "challenge",
"headers": {
"x-bot-challenge": "javascript-pow",
"x-bot-score": f"{score:.3f}",
},
}
else:
return {
"action": "allow",
"headers": {
"x-bot-score": f"{score:.3f}",
},
}
The MIN_REQUESTS_DENY guard prevents false positives on sessions that have only made two or three requests. The timing regularity and graph entropy signals require enough history to be statistically meaningful, and the guard ensures the scoring pipeline does not issue denials before that history accumulates.
Expected Behaviour
After this hardening is in place, the following behaviours apply.
An OpenAI Operator agent that begins navigating your site exhibits goal convergence — it proceeds from the landing page through authentication directly to the target resource, with no back-navigation. After 8–10 requests, the goal convergence score and timing regularity score combine to push the session above the challenge threshold. The agent receives x-bot-challenge: javascript-pow headers and the application presents a PoW challenge page. If the agent is programmed to solve PoW challenges, it does so, receives a clearance cookie, and continues. The clearance cookie has a 10-minute expiry and incurs per-challenge latency — at scale, this materially increases the cost per agent session.
An agent that explicitly sets a user-agent containing anthropic-computer-use or openai-operator tokens is hard-matched on the first request and receives x-bot-decision: deny immediately, before any session history accumulates. This covers misconfigured agents or agents running in test mode that do not strip their UA strings.
A human browsing the same site generates high navigational entropy — they visit several different sections, navigate back, have long dwell times on content pages, and fetch sub-resources in parallel bursts. All four signals produce scores below 0.3, and the session receives x-bot-decision: allow throughout.
If the scoring service is unavailable — Redis connection failure, scoring service pod restart, network partition — the ext_authz filter returns failure_mode_allow: true and requests proceed without x-bot-* headers. Downstream applications treat missing headers as “no signal” and apply their default policy. No traffic is blocked due to scorer unavailability.
Verification:
# Hard UA match — expect x-bot-decision: deny on the first request.
curl -sv \
-H "User-Agent: anthropic-computer-use/0.5 Chrome/124.0" \
https://api.example.com/v1/products 2>&1 \
| grep -E "< HTTP|x-bot-decision|x-bot-score"
# Expected: HTTP/2 429, x-bot-decision: deny, x-bot-score: 0.950
# Confirm client cannot smuggle x-bot-decision: allow past the ingress.
curl -sv \
-H "x-bot-decision: allow" \
-H "x-bot-score: 0.000" \
https://api.example.com/v1/products 2>&1 \
| grep "x-bot-decision"
# Expected: x-bot-decision value is scorer-set, not "allow" from the client.
# (Envoy strips the client-supplied header before the scorer sees the request.)
# Confirm scoring service health.
kubectl -n bot-defence exec deploy/bot-scoring-service -- \
grpc_health_probe -addr=localhost:50051
# Expected: status: SERVING
Trade-offs
The 50ms ext_authz timeout adds latency to every request in the critical path. For API endpoints that currently respond in 20–40ms, this is a meaningful overhead — up to 100% latency increase in the worst case where the scorer is slow but within the timeout window. Mitigation is co-location: deploy the scoring service in the same availability zone as the ingress gateway, use the Envoy gRPC cluster’s connection pool to avoid per-request TCP setup, and cache the action decision for a given session ID for 5 seconds in the scorer’s local memory so that high-frequency resource fetches from the same session do not each incur a full Redis round-trip.
Session state in Redis introduces an operational dependency that did not exist before. Redis must be highly available, monitored for memory pressure, and backed up if session state is used for compliance purposes. The allkeys-lru eviction policy means that under severe memory pressure, session history is silently discarded — scoring degrades gracefully but the degradation is not visible unless Redis eviction metrics are monitored explicitly.
Scoring accuracy is low for short sessions. The first four to six requests in a session do not provide enough history for timing regularity or graph entropy signals to be meaningful. The MIN_REQUESTS_DENY guard prevents hard blocks on short sessions, but it also means that an agent that limits itself to five requests per session per IP — rotating sessions aggressively — will never accumulate enough history to score above the denial threshold. This is a real evasion vector against purely session-scoped scoring. Cross-session signals (shared IP subnet, similar session start patterns, common sub-resource sequences) can partially address this but require a more complex feature store and are not included in this baseline implementation.
The goal convergence signal generates false positives on power users who know what they want. A developer who opens your documentation site, navigates directly to the API reference, and reads only pages in /docs/api/ will exhibit high goal convergence. Tune the convergence weights carefully for your site’s navigation graph structure, and instrument false-positive rates by comparing sessions that received x-bot-challenge headers against sessions that subsequently completed human-typical subsequent behaviours (long dwell times, diverse navigation after the challenge, successful WebAuthn assertion).
Failure Modes
The most dangerous failure mode is client header injection. If the request_headers_to_remove configuration in the Envoy filter does not include every x-bot-* header that downstream applications act on, an agent that has observed your header naming scheme can inject x-bot-decision: allow and skip all challenge routing. Verify this by sending requests with spoofed headers from outside the cluster and confirming the headers are overwritten by scorer-generated values before reaching the application. A one-time audit is not sufficient — this must be part of your regression test suite for any Envoy configuration change.
Redis memory exhaustion causes silent score degradation. When allkeys-lru eviction is active and the eviction rate is high, active sessions lose their history and score near zero on their next request. The session appears new to the scorer. An attacker who induces memory pressure — by creating large numbers of short sessions to fill Redis — can force all existing sessions to lose their accumulated score and avoid detection. Mitigation: monitor the Redis eviction key metrics (evicted_keys in Redis INFO stats) and alert when eviction rates exceed a threshold. Size Redis memory for 2× expected peak concurrent session count.
Threshold misconfiguration causes either mass false-positives or no effective blocking. The default thresholds (0.45 for challenge, 0.75 for deny) are derived from the described signal weights and assume traffic that is predominantly human. If your site has legitimate high-frequency automated API clients — CI pipelines, monitoring checks, integration tests — these will exhibit high timing regularity and goal convergence and will score above the challenge threshold. Allowlist known legitimate automation by source IP range or service account token before deploying the scorer to production. Verify the allowlist is applied in the Envoy route configuration, not in the scorer logic — scoring service bypasses should be handled at the routing layer, not by making the scorer aware of every legitimate automation client.
The failure_mode_allow: true setting means that when the scoring service is entirely unavailable, no bot signals are emitted and all downstream challenge routing silently stops. This is the correct operational tradeoff — availability over security — but it creates a detection gap. If an attacker can force the scoring service to be unavailable (by overloading Redis, by sending requests that cause the scorer to panic and restart, or by exploiting a bug in the gRPC service), they can operate without scoring during the outage window. Mitigation: instrument the presence of x-bot-score headers in access logs and alert when the header is absent from a statistically significant fraction of requests — this is a proxy signal for scorer unavailability that fires even when the scorer’s own health metrics are not visible.
Novel agent frameworks do not trigger the hard UA matching rules. The anthropic-computer-use, openai-operator, and copilot-browser UA patterns cover known agents running in their default configuration. A custom framework built on Playwright, browser-use, or any open-source headless-browser-plus-LLM stack will not match these patterns. All detection for custom frameworks relies on the behavioural signals alone. These signals degrade for agents that are explicitly designed to evade them: an agent that browses several unrelated sections before navigating to its target, pauses with random dwell times sampled from a realistic distribution, and pre-fetches sub-resources in parallel before processing them will score significantly lower. The correct long-term response is to train a supervised model on labelled session data from your specific site rather than relying on the hand-tuned heuristics described here. This implementation provides the scoring infrastructure (session state, feature extraction, ext_authz integration) that a trained model can plug into.