Agent Gate¶
agentsniff gate decides, per inbound request, whether a client is an AI agent and whether to let it through. It runs beside your reverse proxy as a forward-auth endpoint: the proxy asks /check before serving each request, and the gate answers allow, challenge, or block.
The scanner looks outward from your network for agent infrastructure. The gate looks at clients coming in. Both use the same split between presence (what a client says it is) and identity (what a signature proves it is).
Typical use: a bank or fintech that must keep personal AI agents such as Meta Muse and xAI Grok out of authenticated pages, and must stop rapid automated traffic, without blocking customers.
Quick start¶
agentsniff gate # monitor mode on 127.0.0.1:9091
agentsniff gate --enforce # start answering 401/403
agentsniff gate --config agentsniff.yaml --decision-log /var/log/agentsniff/gate.jsonl
The gate starts in monitor mode: it logs what it would do and always answers allow. Run it that way first, read the decision log, tune, then switch to --enforce or gate.mode: enforce.
The gate refuses to start unless the signed agent_clients and tls_fingerprints datasets verify against the packaged signing_key.pub.
How a decision is made¶
- Rate. Every client has a burst counter (20 requests per second) and a sustained counter (300 per minute). A client is an IPv4 address or an IPv6
/64(rate.ipv6_prefix), since one host routinely holds a whole/64. Exceeding either limit appliesgate.rate.action(default block) and holds the client in a penalty box forpenalty_secs(default 300). This applies to every client, verified agents included, and can only make an outcome stricter. If the client address resolves to one of yourtrusted_proxies, the proxy did not forward it; the gate then skips rate limits for that request instead of putting every customer in one bucket, logs a warning, and counts it in/statsasunresolved_client_ip. - Tripwires. A request for a path in
gate.tripwires(URLs you plant where only machines read; see Tripwires) gets the tripwire action (default block) and its client is held in the penalty box fortripwires.penalty_secs(default 3600). Like rate, this applies to every client and can only make an outcome stricter. - Web Bot Auth. A request tagged
web-bot-authwhose RFC 9421 signature verifies against an operator-pinned Ed25519 key is attributed withtrusted_verifiedassurance. Aweb-bot-authsignature that does not verify, or aSignature-Agentheader with no signature, is still a self-declaration that the client is an agent. HTTP signatures without theweb-bot-authtag, such as PSD2 or other API request signing, are ignored. - Self-declared agents. User-agent tokens and
Signature-Agentorigins from the catalog (for examplemuse-,meta-webindexer,GrokAgent,ChatGPT-User) attribute the request withself_declaredassurance. A claim that names no catalog agent is attributed tounidentified_signed_agent. - Behavior. Every request without a verified signature is scored from the signals below, combined with noisy-OR. Naming an agent does not switch these checks off.
- Decision. A verified agent gets its policy: allow if it is in
allow_verified_agents, elseagent_actions[id],kind_actions[kind], ordefault_agent_action(block). A claimed agent gets the stricter of that policy and its behavior score, so a claim can never soften an outcome. An unattributed request is challenged atchallenge_threshold(0.5) and blocked atblock_threshold(0.8).
| Signal | Weight | Fires when |
|---|---|---|
browser_ua_without_browser_headers |
0.50 | A browser user agent sends neither Accept-Language nor Sec-Fetch-* |
missing_accept_language |
0.25 | A browser user agent sends Sec-Fetch-* but no Accept-Language |
missing_user_agent |
0.50 | No User-Agent |
automation_client |
0.50 | python-requests, Go-http-client, curl/, HeadlessChrome, and similar |
stale_browser_version |
0.25 / 0.40 | Chrome at least 6 (or 12) majors behind the catalog's stable baseline |
hosting_network |
0.35 / 0.25 | Origin ASN is a cloud, VPN, or proxy host (browser UA / other UA) |
agent_operator_network |
0.60 | A browser user agent from the network an agent runs on, such as Meta's AS32934; attributes the agent as suspected |
cdn_egress_browser |
0.50 | Chrome on desktop Linux from a CDN egress network (Cloudflare, Fastly): the exit the ChatGPT cloud browser, Muse, and Grok Bot use. Challenges alone, never blocks alone |
tls_user_agent_mismatch |
0.50 | Claims a browser, but the JA4 matches an HTTP library |
tls_client_library |
0.30 | JA4 matches an HTTP library |
tripwire_hit |
1.00 | The path is in gate.tripwires; the client is held in the penalty box |
The ASN and JA4 signals need your edge to supply those values (see Edge headers). The header and browser-version checks apply only to user agents that claim to be a browser. Other user agents get the automation-token, network, and TLS checks only.
What this catches, and what it does not¶
- Self-identifying agents and crawlers are matched by name and blocked by default.
- ChatGPT cloud browser (dots) presents stock Chrome on Linux with no token and signs requests with Web Bot Auth as
https://chatgpt.com. A verified signature attributes it asopenai_chatgpt_cloud_browserand your policy decides; pin the key only if you want every ChatGPT cloud-browser session, dots included, admitted. Unsigned, it is challenged bycdn_egress_browserwhen your edge supplies the ASN. A dot working through the user's own computer is that person's browser and passes. - Meta Muse in browser mode has the same profile: a cloud Chromium presenting Chrome on Linux with no token, exiting from Cloudflare and Fastly addresses and almost never from Meta's AS32934. It is challenged by
cdn_egress_browser(with an ASN header), blocked when it sends the reportedmuse-orMeta-ExternalTesttokens, and rate-limited when it moves faster than a person. Its text-only fetches arrive asmeta-webindexerand are blocked by name. Meta signs a small, growing share of requests with Web Bot Auth but publishes noSignature-Agentorigin, so nothing can be pinned yet. - Grok is two clients. The chat fetcher spoofs iPhone Safari, macOS Chrome, or Go-http-client from rotating datacenter and proxy IPs and usually carries several weak signals at once; how far it gets depends on how many fire. A Chrome 139 user agent with no browser headers from M247 scores 0.805 and is blocked. The same request with Chrome 150 scores 0.756, and without an ASN header 0.70; both are challenged. Bursty timing adds a rate block on top. Grok Bot, the agent, is blocked by name when it sends
GrokAgentor signs ashttps://cursorusercontent.com; when it does neither and drives a headful browser through the user's own connection, it is the real-browser case below. - Non-browser clients with an unlisted user agent (a custom string such as
Goorx) get only network and TLS signals. From a residential address they pass; from a hosting network they score 0.25. - An agent in a real browser, on a residential proxy, at human pace will pass. No request-level gate stops that alone. Pair the gate with controls at login and transaction time, such as device binding and step-up authentication.
User-agent tokens and Signature-Agent origins are self-asserted. An allow in agent_actions or kind_actions (for example kind_actions: {crawler: allow}) can be claimed by any client that sends that name; such clients are still scored, but a plain spoof from a clean network passes. Use allow_verified_agents to admit agents by identity.
Proxy configuration¶
Every integration follows the same contract: 2xx allows, the challenge status (default 401) and block status (default 403) deny. The gate reads the original request from X-Forwarded-Method, X-Forwarded-Uri, X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-For (or X-Original-Method / X-Original-URI). It honors these only from trusted_proxies (default loopback). The X-Forwarded-* names win over X-Original-*, so always set every header you rely on at the proxy; a header you leave unset passes through whatever the client sent.
The client address must be the one your proxy saw. The gate walks X-Forwarded-For from the right, skipping trusted_proxies, and accepts ip, ip:port, and [ipv6]:port entries. A proxy that forwards a client-supplied X-Forwarded-For without appending the real peer lets every client pick its own address and escape rate limits.
If the gate is unreachable, these configurations fail closed. To fail open instead, use your proxy's equivalent of Envoy's failure_mode_allow.
nginx¶
location / {
auth_request /_agentsniff;
auth_request_set $as_decision $upstream_http_x_agentsniff_decision;
auth_request_set $as_agent $upstream_http_x_agentsniff_agent;
# To your application, not to the client:
proxy_set_header X-AgentSniff-Decision $as_decision;
proxy_set_header X-AgentSniff-Agent $as_agent;
error_page 401 = @agent_challenge;
proxy_pass http://app_backend; # not `return`: it runs before auth_request
}
location @agent_challenge {
# Send challenged clients to your CAPTCHA or step-up flow.
return 302 /verify-human?next=$request_uri;
}
location = /_agentsniff {
internal;
proxy_pass http://127.0.0.1:9091/check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-AgentSniff-Gate-Token "<token>"; # omit if the gate runs tokenless on loopback
# proxy_set_header X-ASN $geoip2_asn; # if you run the geoip2 module
}
nginx's return directive runs in the rewrite phase, before auth_request, so a location that answers with return is never checked. Put auth_request only on the locations you want gated, such as login, accounts, and transfers.
auth_request understands only 401 and 403 as denials and turns any other status into a 500. Keep challenge_status and block_status at 401 and 403 with nginx; the other proxies accept any 4xx.
Caddy¶
bank.example {
forward_auth 127.0.0.1:9091 {
uri /check
header_up X-AgentSniff-Gate-Token {env.AGENTSNIFF_GATE_TOKEN}
copy_headers X-AgentSniff-Decision X-AgentSniff-Agent
}
reverse_proxy app:8080
}
Traefik¶
http:
middlewares:
agentsniff-token:
headers:
customRequestHeaders:
X-AgentSniff-Gate-Token: "<token>"
agentsniff-gate:
forwardAuth:
address: http://agentsniff:9091/check
trustForwardHeader: false # the default; Traefik sets X-Forwarded-For itself
authResponseHeaders: [X-AgentSniff-Decision, X-AgentSniff-Agent]
routers:
bank:
rule: Host(`bank.example`)
middlewares: [agentsniff-token, agentsniff-gate]
service: app
With Traefik, add Traefik's address to gate.trusted_proxies. Traefik and Caddy copy the listed headers to the upstream request on allow; on a denial they return the gate's own response to the client.
Envoy¶
Envoy must append the real peer address to X-Forwarded-For. Its default (use_remote_address: false) passes the client's header through untouched, which lets clients choose their own address, and collapses clients that send none onto Envoy's address. Set this on the HTTP connection manager:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
failure_mode_allow: false
http_service:
server_uri: { uri: http://127.0.0.1:9091, cluster: agentsniff_gate, timeout: 0.25s }
path_prefix: /check
authorization_request:
allowed_headers:
patterns:
- exact: user-agent
- exact: accept-language
- prefix: sec-fetch-
- prefix: signature
- exact: x-forwarded-for
- exact: x-forwarded-proto
headers_to_add:
- key: x-agentsniff-gate-token
value: "<token>"
Envoy appends the original path to /check (a request for / arrives as /check/); the gate strips the prefix.
Apache httpd¶
Apache httpd has no equivalent of auth_request or forward_auth. The
bundled mod_authnz_fcgi speaks FastCGI rather than HTTP, and
mod_authnz_external forces Basic authentication, so neither fits a gate that
answers allow, challenge or block per request. Run one of the four proxies
above in front of Apache and point its forward-auth at the gate; Apache stays
the origin.
Edge headers¶
Two optional signals depend on data only the edge has.
- ASN (
gate.asn_header, defaultx-asn): the client's origin autonomous system number, with or without anASprefix. With nginx, use the geoip2 module and a GeoLite2-ASN database. On Cloudflare, a request-header transform rule can set it fromip.src.asnum. - JA4 (
gate.ja4_header, defaultx-ja4): the client's JA4 TLS fingerprint, from a JA4-capable TLS terminator or your CDN's bot-management fields.
Without them the gate still runs; it just has fewer signals.
Admitting verified agents (Web Bot Auth)¶
Some agents sign requests with Web Bot Auth (RFC 9421 HTTP Message Signatures). The gate verifies Ed25519 signatures against keys you pin. It never fetches the key directory named in Signature-Agent: a client-chosen URL on the request path would be an SSRF and latency problem.
Fetch an agent's directory yourself, check it, and pin the key:
gate:
allow_verified_agents: [openai_chatgpt_cloud_browser]
web_bot_auth:
trusted_keys:
- agent_id: openai_chatgpt_cloud_browser
signature_agent: "https://chatgpt.com"
jwk: { kty: OKP, crv: Ed25519, x: "<base64url public key>" }
# keyid defaults to the RFC 7638 JWK thumbprint, which Web Bot Auth requires
This key signs for every ChatGPT cloud-browser session, dots included, so pinning it admits ChatGPT browsing in general, not one agent. Grok Bot's directory is on cursorusercontent.com, the cloud computers it runs on; Muse has no published directory.
Verification requires:
- the
web-bot-authtag and a pinnedkeyid; createdandexpireswithinclock_skew_secs(default 60) of now, and no more thanmax_lifetime_secsapart (default 1 hour; the draft allows up to 24 hours, so raise it if an agent you admit signs longer and the decision log showslifetime_too_long);- coverage of
@authority(or@target-uri), and ofSignature-Agentwhen the header is present; - a nonce not seen before (
require_nonce, default true). Without a nonce a captured request replays until it expires.
The nonce cache lives in one gate process. With several gate instances, a captured request can be replayed once per instance within its lifetime, so keep the lifetime short. Set web_bot_auth.authorities to the hosts you serve (for example ["bank.example"]) so a signature made for another site cannot verify here even if a proxy forwards a client-supplied Host. Re-pin when the agent rotates keys.
For "signature-agent";key="..." the gate signs over the dictionary member value, as RFC 9421 section 2.1.2 and the draft's test vectors do.
Responses and logs¶
Every /check response carries X-AgentSniff-Decision and X-AgentSniff-Request-Id. Allow responses, which the proxy copies to your application, also carry X-AgentSniff-Would-Decision, X-AgentSniff-Score, X-AgentSniff-Reasons (signal ids), and, when attributed, X-AgentSniff-Agent and X-AgentSniff-Assurance (trusted_verified, self_declared, or suspected). Challenge and block responses omit those by default, because Traefik, Caddy, and Envoy relay a denial's headers to the client and they would tell an evader what to change. Set deny_details: true to include them while debugging; the decision log always has the full detail.
Every non-allow decision, and every attributed request, is written as one JSON line to stdout or gate.decision_log (off disables it). Set log_allows: true to log everything. The lines suit Wazuh, Splunk, or any JSON log pipeline.
| Route | Auth | Purpose |
|---|---|---|
/check, /check/* |
gate token | Forward-auth decision |
/decisions?limit=N |
gate token | Most recent logged decisions (up to 1,000) |
/stats |
gate token | Decision counters, including unresolved_client_ip |
/healthz |
none | Liveness |
Configuration¶
gate:
mode: monitor # monitor | enforce
host: "127.0.0.1"
port: 9091
token: "" # prefer AGENTSNIFF_GATE_TOKEN; required off loopback
trusted_proxies: ["127.0.0.1/32", "::1/128"]
client_ip_header: "x-forwarded-for" # or x-real-ip, cf-connecting-ip, ...
ja4_header: "x-ja4"
asn_header: "x-asn"
default_agent_action: block
kind_actions: {} # cloud_browser_agent | user_triggered_agent | crawler
agent_actions: {} # catalog id -> allow | challenge | block
allow_verified_agents: []
custom_agents: [] # same fields as catalog entries
challenge_threshold: 0.5
block_threshold: 0.8
stale_browser_lag: 6
rate:
enabled: true
key: ip # ip | ip_user_agent (see Tuning)
ipv6_prefix: 64 # IPv6 clients are counted per /64
burst_window_ms: 1000
burst_limit: 20
sustained_window_secs: 60
sustained_limit: 300
action: block
penalty_secs: 300 # at most 7 days
max_tracked_clients: 100000 # clients in the penalty box can use up to twice this
tripwires:
paths: [] # exact paths, or prefixes ending in /
action: block # challenge | block
penalty_secs: 3600 # at most 7 days
web_bot_auth:
enabled: true
trusted_keys: []
require_nonce: true
max_lifetime_secs: 3600
clock_skew_secs: 60
max_nonces: 100000
authorities: [] # hosts you serve; empty accepts any
challenge_status: 401 # keep 401/403 with nginx
block_status: 403
deny_details: false
decision_log: ""
log_allows: false
The gate binds to loopback by default. A non-loopback listener requires a token of at least 32 bytes, sent by the proxy in X-AgentSniff-Gate-Token; the client's own Authorization header passes through untouched. A missing or wrong token gets 407. nginx turns that into a 500 and logs an auth error; Traefik, Caddy, and Envoy pass the 407 to the client. Either way the request is denied.
Tuning¶
- Shared IPs. Corporate NAT and carrier-grade NAT put many people behind one address. Raise the limits for those pages first.
rate.key: ip_user_agentalso helps, but a client that changes its user agent on every request then gets a fresh counter each time, so it trades rate protection for fewer false positives. - IPv6. Clients are counted per
/64by default. Raiserate.ipv6_prefixtoward 128 if many unrelated users share a/64, or lower it (to 56 or 48) if attackers rotate across larger allocations. - Scope. Gate the pages that matter (login, accounts, payments) rather than static assets, which browsers fetch in bursts.
- Challenges. A challenge is a deny unless you route it somewhere. Send it to a CAPTCHA or step-up flow, or set
challenge_status: 403.
Tripwires¶
A tripwire is a URL on your site that no person ever requests, because it is planted only where machines read: an HTML comment, a link hidden with display:none, a url in JSON-LD, a Disallow line in robots.txt. List the paths under gate.tripwires.paths. A client that requests one gets tripwire_hit, the configured action, and penalty_secs in the penalty box, so its next requests fail with rate_penalty whatever they ask for. The decision log records the client's user agent, JA4, and ASN with the hit, which is the cheapest labeled fingerprint you will get from live traffic.
<!-- <a href="/.internal/export.csv">export</a> -->
<a href="/private-area/statements" style="display:none" aria-hidden="true" tabindex="-1">statements</a>
- An entry ending in
/matches every path under it; any other entry matches exactly./.internal/export.csv/and/%2einternal/export.csvdo not match the exact entry above, so use the prefix form for a directory and keep exact entries for files. Query strings are never compared. - Keep traps out of the accessibility tree. A link hidden only with CSS is still read aloud; add
aria-hidden="true"andtabindex="-1", or use a comment. Screen-reader users must never trip a wire, which matters for a bank. - Tripwires catch raw-HTML readers: the Grok fetcher, Muse's text fetch, and scripted crawlers. Agents that act on a rendered page's accessibility tree, as Muse's browser mode does, never see them. Do not make traps visible to widen coverage; that is the trade.
tripwires.actiondecides the hit itself. The follow-up requests are answered by the penalty box, which appliesrate.action(default block). If you route challenges to a step-up flow and want the whole sequence challenged, set both tochallenge.- Compliant crawlers follow hidden links. List every tripwire path under
Disallowinrobots.txtas well, so Googlebot and Bingbot never request it; otherwise each visit holds them in the penalty box forpenalty_secs. - With
rate.enabled: false, or when the client address resolves to a trusted proxy, only the single request is denied; there is no penalty box to hold the client in. - Change paths when you rotate pages. A trap that leaks into a sitemap or a real link becomes a false positive for everyone who follows it.
Prompt-injection canaries, hidden instructions that ask an agent to fetch a beacon URL, are a different tool. A hit proves a language model read the page, which separates agents from scripted bots, but security-aware agents recognize and refuse the trap, so yield on frontier agents is low and the hit measures injection resistance more than identity. Run them only on a research domain you own. Never ship them in a customer deployment, where you would be injecting instructions into agents that hold real customers' credentials.
The catalog¶
Known agents live in the signed agent_clients.json dataset: user-agent tokens, Signature-Agent origins, operator ASNs, automation tokens, hosting ASNs, CDN egress ASNs, and the Chrome stable baseline. Each entry records:
status, its provenance, in order of trust:lab_observed(identifiers captured on an origin we control with a per-run nonce URL, so attribution is exact),operator_documented,cdn_directory, andthird_party_reported. The gate refuses any other value.last_confirmed, theYYYY-MM-DDthe identifiers were last checked against a live source, or empty when they never were. Only the ChatGPT cloud browser and Grok Bot carry a date today: their key directories were fetched. Treat an old or empty date as a reason to re-verify before you build policy on the entry.aliases, the other names readers know the agent by (dots,GrokAgent,Meta-WebIndexer), for search and for mapping old ids.sourcesandnotes.
Muse's and Grok Bot's tokens are third-party reports and a CDN directory listing, not vendor documentation. The Grok chat fetcher has no entry because it has no stable identifier; it is the behavior profile described above.
Update the dataset with agentsniff update-signatures. Add your own entries under gate.custom_agents, with the same fields.
Keeping the catalog current¶
All three agents this gate is tuned for changed how they present themselves in October 2026: OpenAI started signing its cloud browser, Meta started signing a share of Muse traffic, and Grok Bot stopped. A catalog that is not re-checked is stale within weeks, and a stale entry is the biggest risk this gate carries.
The only ground truth is a labeled capture: pay for each agent, and give every task you hand it a unique per-run URL on an origin you own, such as https://lab.example.com/r/<agent>/<nonce>. The nonce makes attribution exact, and that capture earns the lab_observed tier. For each run record:
- JA4, JA4H, and JA4T, the HTTP/2
SETTINGSand priority frames, header order, and client hints; - ASN, IPv4 versus IPv6, and whether the exit is a CDN, a cloud, or the user's own connection;
- timing between requests and the full subresource sequence, which separates a text fetch (no JavaScript, no assets) from a rendered browser.
Re-run weekly and after every vendor announcement, diff against the previous capture, and update last_confirmed for what still holds. Where a cloud build of Chromium differs from retail Chrome of the same version in its HTTP/2 or JA4H behavior, that difference is a candidate signal; the gate does not carry one until the lab has shown it.
The gate is header-only by design: it sees one request at a time plus rate counters. A session layer (asset-to-document ratio, deep links without a referrer chain, uniform action timing) or a JavaScript probe (software GL renderer, timezone against IP geography, no pointer movement before clicks) is where commercial vendors catch Muse and Grok Bot in a real browser. Either is a separate decision, because it changes what the gate is.