What is Web Bot Auth?
Web Bot Auth is a way for bots and AI agents to prove who sent an HTTP request. The operator publishes public keys at a well-known URL on its own domain and signs each request with the matching private key. Your server checks the signature against those keys, and a valid signature tells you which operator sent the request – something a User-Agent string or an IP address can't prove.
Every signed request carries three headers:
Signature-Agentnames the operator's origin, such ashttps://chatgpt.com, where the verifier finds the keys.Signature-Inputlists the parts of the request that are signed and the signature's parameters: when it was created, when it expires, which key signed it, and the tagweb-bot-auth.Signaturecarries the signature bytes.
The signature format is RFC 9421 HTTP Message Signatures, a published IETF standard. Web Bot Auth adds a profile on top of it: which components to sign, how to name the operator, and how to publish keys. That profile is an IETF draft.
ChatGPT's cloud browser, Google's Google-Agent, DuckDuckGo's DuckAssistBot, Browserbase, and Manus all sign requests. Arcjet verifies those five operators by name in detectBot, and any other operator that publishes a key directory, through a SIGNED_BOT: entry. The rest of this guide covers how the protocol works, how to sign and verify requests yourself, and where the security edges are.
For more information about the other ways to identify bots, see how to identify AI agents and bots.
Why isn't a User-Agent or IP address enough?
User-Agent strings and IP addresses identify crawlers well, but they can't identify a browser agent. A crawler such as Googlebot sends its own User-Agent token from address ranges that Google publishes, so you can check one against the other. For more information about that check, see detect bot spoofing.
Browser agents don't fit that model. ChatGPT's agent, Manus, and agents built on Browserbase run a real Chromium browser and typically send a standard Chrome User-Agent. Their traffic comes from cloud networks that many companies share. When we captured a ChatGPT agent session, the source IP belonged to Cloudflare and geolocated near the person who started the task. Nothing in the headers or the address said "ChatGPT".
Shared browser infrastructure makes IP addresses even less specific. Browserbase, Kernel, and Cloudflare Browser Run each host browsers for many customers, so an address in their range tells you which platform the request came from, not which customer's agent sent it. Operators also renumber, so an address list that you copy into code goes stale.
A signature moves identity from the network to a key. The operator controls the private key, the public key lives on the operator's domain, and the signature covers the request itself. The agent can run anywhere, and you can still check who sent it.
How does Web Bot Auth work?
Web Bot Auth works in four steps:
- The operator generates a key pair, usually Ed25519.
- The operator publishes the public key in a JSON Web Key Set (JWKS) at
/.well-known/http-message-signatures-directoryon its origin. - For each request, the operator signs selected components of the request and adds the three headers.
- Your server reads
Signature-Agent, gets the keys from that origin's directory, and verifies the signature.
The following request is one that the signer in this guide produced. It's a GET /products/42 to shop.example:
GET /products/42 HTTP/1.1Host: shop.exampleSignature-Agent: sig1="https://bot.example.com"Signature-Input: sig1=("@authority" "signature-agent";key="sig1" "@method" "@path");created=1792173296;keyid="QhAjsBr9Qz60TFnCwle62E6IqwfYjuFIfqEkw2OK2Rc";alg="ed25519";expires=1792173356;nonce="qNjWQoTvtbv5gDBrjxEGfWfmHIjYoIdLDzP78aHr/GnEIMKRPGTwGgIac0ryR+TzXrRudXfSmc0QZqrejUkqkg==";tag="web-bot-auth"Signature: sig1=:pqmlxVIwFCbF5vKb8xKHdei4mnDYct3U808+RHnX83n88V0hnOzwqzN8MTWNAwjlzOS1q7gVryo1LRsOGwYVAw==:sig1 is a label. It ties the Signature-Input member, the Signature member, and the Signature-Agent member together, so one request can carry several signatures from different signers.
What exactly gets signed?
The signer doesn't sign the raw request. It builds a short text document called the signature base, with one line per covered component and a final line that repeats the signature parameters. For the preceding request, the signature base is the following:
"@authority": shop.example"signature-agent";key="sig1": "https://bot.example.com""@method": GET"@path": /products/42"@signature-params": ("@authority" "signature-agent";key="sig1" "@method" "@path");created=1792173296;keyid="QhAjsBr9Qz60TFnCwle62E6IqwfYjuFIfqEkw2OK2Rc";alg="ed25519";expires=1792173356;nonce="qNjWQoTvtbv5gDBrjxEGfWfmHIjYoIdLDzP78aHr/GnEIMKRPGTwGgIac0ryR+TzXrRudXfSmc0QZqrejUkqkg==";tag="web-bot-auth"The verifier rebuilds the same document from the request that it received and checks the signature over it. If a proxy changes the host, a framework normalizes the path, or anyone swaps the Signature-Agent value, the rebuilt document is different and the signature fails.
The covered components decide what a signature protects. The following table lists the parameters and components that matter for Web Bot Auth:
| Item | Required | What it does |
|---|---|---|
@authority | Yes (or | Binds the signature to one host, so it can't be replayed against another site. |
signature-agent | Yes | Binds the operator's identity to the signature, so the
|
| Recommended | Narrows the signature to one method and one resource on that host. |
| Yes | Set the validity window. The draft recommends no more than 24 hours. |
keyid | Yes | Selects the key. It's the key's RFC 7638 JWK thumbprint, a SHA-256 hash of the public key. |
tag | Yes | Always |
nonce | Optional | A random value that a verifier can record to reject reuse of the same signature. |
Real signers make different choices. A ChatGPT agent request that SeatGeek published in its write-up covers @authority, @method, @path, and signature-agent, with a one-hour expiry. A DuckAssistBot request that a site owner published covers only @authority and signature-agent, with a 10-minute expiry. Both are valid. The DuckAssistBot signature is valid for any path on that host until it expires.
Why are there two forms of Signature-Agent?
The Signature-Agent header has two forms because the draft changed. Earlier versions defined it as a quoted string, which is the form that ChatGPT's cloud browser sends:
Signature-Agent: "https://chatgpt.com"The current draft makes it a dictionary keyed by the signature label, which is what lets several signers share one request. Google's agent uses this form, with the label g:
Signature-Agent: g="https://agent.bot.goog"The draft says that signers must send the dictionary form and verifiers may accept either. Verifiers differ in practice: Cloudflare's documentation says that its verifier rejects the dictionary form, and Fingerprint's test endpoint rejects it too. Arcjet accepts both. If you operate a bot, check which form the sites that you need to reach can verify. The signer in this guide sends the dictionary form. To send the string form instead, set Signature-Agent to "https://bot.example.com", including the quotation marks, and the web-bot-auth package covers the whole header.
What does a key directory look like?
A key directory is a JSON Web Key Set served over HTTPS at /.well-known/http-message-signatures-directory, with the media type application/http-message-signatures-directory+json. Because the path is a well-known URI on the operator's own origin, a valid signature ties the request to whoever controls that domain. The following example shows a directory response with one Ed25519 public key:
HTTP/1.1 200 OKContent-Type: application/http-message-signatures-directory+jsonCache-Control: public, max-age=3600
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "OJcT7UnEjXv9S39alnifmm5TgYNEmhDum3aQzbh0HYc" } ]}The Cache-Control header tells verifiers how long they can keep the keys. Operators can also sign the directory response itself with the tag http-message-signatures-directory, which proves that whoever served the directory holds the matching private keys. The draft recommends that signature, and Cloudflare requires it when an operator registers a bot. Every directory in the following table signs its responses.
On October 16, 2026, we fetched the directories of the five operators that Arcjet verifies by name. They show how much real directories vary:
| Operator | Directory origin | Keys | Cache-Control |
|---|---|---|---|
Google ( | https://agent.bot.goog | 5, with short | public, max-age=3600 |
| ChatGPT cloud browser | https://chatgpt.com | 1, with | no-store, no-transform |
| DuckAssistBot | https://assistbot.duckduckgo.com | 1 | max-age=86340 |
| Browserbase | https://www.browserbase.com | 2 | public, max-age=3600 |
| Manus | https://api.manus.im | 1, with no | max-age=86400 |
Two of these details break naive verifiers. Google's kid values aren't the thumbprints that the draft calls for, so a verifier that looks keys up only by thumbprint never finds Google's keys. ChatGPT's directory forbids caching, so a verifier that honors no-store has to read the directory again for every signed request.
Is Web Bot Auth a standard?
The signature format is a standard; the Web Bot Auth profile isn't yet. RFC 9421 was published in February 2024. The profile, the Signature-Agent header, and the key directory are in draft-ietf-webbotauth-httpsig-protocol, a working-group draft of the IETF Web Bot Auth working group, dated September 1, 2026. It began as two individual drafts, one for the signing profile and one for the directory. The authors folded the directory draft into the profile draft, and the working group adopted the combined document. Details can change before it becomes an RFC, as the two Signature-Agent forms show.
Google describes its implementation as experimental. Google is testing signatures with some AI agents hosted on Google infrastructure, doesn't sign every request from those agents, and tells site owners to keep verifying by IP address, reverse DNS, and User-Agent. That advice applies to every operator. A missing signature isn't evidence of a fake, so keep your other checks in place.
Many security vendors verify the signatures. OpenAI's allowlisting guide lists Akamai, Cloudflare, HUMAN, and Vercel as verifying ChatGPT's signatures, and Fingerprint supports signed agents too. Those products decide at the edge, before the request reaches your code. Arcjet verifies signatures as part of the bot rule that you call from your route handler, so the decision sits next to the rest of your application logic.
Which bots and AI agents sign their requests?
Arcjet maintains the open source well-known-bots catalog, which records the signing directory for each operator that publishes one. Arcjet Cloud verifies the following operators by their bot ID:
| Arcjet bot ID | Operator | What a valid signature identifies | Categories |
|---|---|---|---|
CHATGPT_AGENT | OpenAI | ChatGPT's cloud browser. It's distinct from
| CATEGORY:AI |
GOOGLE_AGENT |
|
| |
DUCKASSIST_BOT | DuckDuckGo | DuckAssistBot, which fetches pages for DuckDuckGo's AI answers. |
|
BROWSERBASE | Browserbase | A browser session on Browserbase. It doesn't identify the customer whose agent runs in that session. |
|
MANUS_BOT | Manus | The Manus agent. | CATEGORY:AI |
The catalog lists more signing directories, including Exa, You.com, Context7, Shopify Cloudsurf, Kernel, and Cloudflare Browser Run. To trust any of them in Arcjet, use a SIGNED_BOT: entry with the directory URL, which the Arcjet section explains.
Read the third column of the table carefully. A valid signature identifies the operator that holds the key. When the operator is a platform, such as a cloud browser vendor, the signature proves that the request came through that platform. It doesn't tell you which of the platform's customers sent it, or which person asked the agent to act. For more information about delegated identity, see AI agent on-behalf-of identity.
How do you sign requests as a bot operator?
To sign requests, generate a key, publish its public half in a directory on your domain, and add the three headers to each request. The following examples use Node.js 24 and the web-bot-auth package from Cloudflare, version 0.2.0. We ran each example in this guide end to end, and Arcjet's verifier accepted the signatures.
Generate a signing key
The following script generates an Ed25519 key pair with the Web Crypto API and prints the private key, the public key, and the key's thumbprint:
import { createHash } from "node:crypto";
const { privateKey, publicKey } = await crypto.subtle.generateKey( { name: "Ed25519" }, true, ["sign", "verify"],);
// Node exports alg "Ed25519"; JOSE and the web-bot-auth package use "EdDSA".const privateJwk = { ...(await crypto.subtle.exportKey("jwk", privateKey)), alg: "EdDSA",};const { kty, crv, x } = await crypto.subtle.exportKey("jwk", publicKey);
// RFC 7638 thumbprint: the required members, in lexicographic order.const keyid = createHash("sha256") .update(JSON.stringify({ crv, kty, x })) .digest("base64url");
console.log(JSON.stringify({ keyid, privateJwk, publicJwk: { kty, crv, x } }));Store the private JWK in your secret manager. It contains the private member d, so it never goes in the directory. The thumbprint is the keyid that verifiers look up. Computing the thumbprint yourself shows exactly what it is: a SHA-256 hash of crv, kty, and x, in that order.
The alg override isn't cosmetic. Node exports Ed25519 keys with "alg": "Ed25519", and the web-bot-auth package rejects that value. It expects "EdDSA".
Publish the key directory
The following handler serves the directory. It uses the standard Request and Response types, so it runs as a Next.js route handler, a Cloudflare Worker, or a Bun or Deno server:
const publicJwk = JSON.parse(process.env.WBA_PUBLIC_JWK);
export function handleDirectory(request) { const { pathname } = new URL(request.url); if (pathname !== "/.well-known/http-message-signatures-directory") { return new Response("Not found", { status: 404 }); }
return new Response(JSON.stringify({ keys: [publicJwk] }), { headers: { "Content-Type": "application/http-message-signatures-directory+json", "Cache-Control": "public, max-age=3600", }, });}Serve the directory with a 200 status code on the exact origin that you put in Signature-Agent, without a redirect. Verifiers that follow the draft treat any other status, and any redirect, as a failure. Keep the media type exact too: Arcjet rejects a directory served as application/json.
Sign each request
The following function wraps fetch and signs every request that it sends:
import { generateNonce, sign } from "web-bot-auth";import { signerFromJWK } from "web-bot-auth/crypto";
const signer = await signerFromJWK(JSON.parse(process.env.WBA_PRIVATE_JWK));const SIGNATURE_AGENT = 'sig1="https://bot.example.com"';
export async function signedFetch(url, init) { const request = new Request(url, init); request.headers.set("Signature-Agent", SIGNATURE_AGENT);
const created = new Date(); const { signature, signatureInput } = await sign(request, { signer, label: "sig1", signatureAgentKey: "sig1", additionalComponents: ["@method", "@path"], created, expires: new Date(created.getTime() + 60_000), nonce: generateNonce(), });
request.headers.set("Signature", signature); request.headers.set("Signature-Input", signatureInput); return fetch(request);}The defaults in this function follow from the security tradeoffs:
- Cover
@methodand@path. Without them, anyone who sees one signed request can replay it against any path on that host until it expires. - Keep the expiry short. Cloudflare suggests about a minute, which allows for clock skew and transit time while limiting replay.
- Sign the request that you actually send. If a proxy or HTTP client rewrites the path or host after signing, the signature fails.
- Avoid non-ASCII header values in covered components. RFC 9421 requires them to be wrapped as byte sequences with the
bsparameter, and some verifiers, including Cloudflare's, don't support it.
Rotate keys without breaking verification
Verifiers cache your directory for as long as your Cache-Control header allows, so rotate in the following order:
- Add the new public key to the directory next to the old one.
- Wait at least one cache lifetime, the
max-agevalue, so that verifiers fetch the new directory. - Start signing with the new key.
- After a further cache lifetime plus your signature lifetime, remove the old key.
If a private key leaks, remove its public key from the directory immediately. Verifiers that cached the directory keep accepting the leaked key until their copy expires. Your max-age value is therefore also your worst-case revocation delay. Choose it with that in mind.
Test your signatures
You can test signed requests against several public endpoints:
- Fingerprint's Web Bot Auth test page reports whether a signature validates, with no account. It's stricter than the draft: it requires a
nonceand accepts only the string form ofSignature-Agent. - Cloudflare's test endpoint at
https://crawltest.com/cdn-cgi/web-bot-authreturns200for a verified signature,401for an unknown key or a signature that fails, and400for a request that it can't parse. Cloudflare knows only keys that are registered through its bot submission form, so expect401until you register. - If your site uses Arcjet, add your directory as a
SIGNED_BOT:entry in aDRY_RUNrule and send signed requests to it. The first request starts a background key fetch, so check the decision for a later request.
How do you verify Web Bot Auth signatures?
To verify signatures yourself, use a library for the RFC 9421 parsing and cryptography, and write the trust policy around it: which operators you trust, how you fetch and cache their keys, and what happens when anything fails. Most of the risk lives in the policy, not the cryptography.
The following verifier uses web-bot-auth on the same Node.js 24 setup as the signer, and accepts only the operators that you list:
import { verify } from "web-bot-auth";import { verifierFromJWK } from "web-bot-auth/crypto";
const DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
// Only these operators can verify. A request header never adds to this list.const TRUSTED_DIRECTORIES = new Set([ "https://agent.bot.goog" + DIRECTORY_PATH, "https://chatgpt.com" + DIRECTORY_PATH, "https://bot.example.com" + DIRECTORY_PATH,]);
const MAX_TTL_MS = 60 * 60 * 1000;const directories = new Map(); // directory URL -> { expiresAt, keys }
async function loadKeys(directoryUrl) { const cached = directories.get(directoryUrl); if (cached && cached.expiresAt > Date.now()) return cached.keys;
const response = await fetch(directoryUrl, { headers: { Accept: "application/http-message-signatures-directory+json" }, redirect: "manual", signal: AbortSignal.timeout(2_000), }); const type = response.headers.get("Content-Type")?.split(";")[0].trim(); if ( response.status !== 200 || type !== "application/http-message-signatures-directory+json" ) { throw new Error("directory unavailable"); }
const now = Date.now() / 1000; const keys = new Map(); for (const jwk of (await response.json()).keys ?? []) { if ((jwk.nbf && jwk.nbf > now) || (jwk.exp && jwk.exp <= now)) continue; try { const verifier = await verifierFromJWK(jwk); // Signers use the RFC 7638 thumbprint, or a kid that isn't one // (Google's short kid values). Index the key under both. for (const keyid of new Set([verifier.keyid, jwk.kid])) { if (keyid) keys.set(keyid, { ...verifier, keyid }); } } catch { // Skip key types this verifier doesn't support. } }
// Parse Cache-Control by directive, so no-cache-foo isn't no-cache. const directives = new Map( (response.headers.get("Cache-Control") ?? "").split(",").map((part) => { const [name, value] = part.trim().toLowerCase().split("="); return [name, value]; }), ); const uncacheable = ["no-store", "no-cache", "private"].some((name) => directives.has(name), ); const maxAge = Number(directives.get("max-age") ?? 300); if (!uncacheable && Number.isFinite(maxAge)) { const ttl = Math.min(maxAge * 1000, MAX_TTL_MS); directories.set(directoryUrl, { expiresAt: Date.now() + ttl, keys }); } else { directories.delete(directoryUrl); } return keys;}
/** Returns the verified operator's directory URL, or throws. */export async function verifySignedBot(request) { let directoryUrl; await verify(request, { maxAge: 24 * 60 * 60, clockSkew: 60, async resolver({ keyid, signatureAgent }) { if (signatureAgent?.type !== "directory") throw new Error("no agent"); const origin = new URL(signatureAgent.uri); if (origin.pathname !== "/" || origin.search || origin.hash) { throw new Error("Signature-Agent must be an origin"); } directoryUrl = origin.origin + DIRECTORY_PATH; if (!TRUSTED_DIRECTORIES.has(directoryUrl)) { throw new Error("untrusted signer"); } const verifier = (await loadKeys(directoryUrl)).get(keyid); if (!verifier) throw new Error("unknown key"); return verifier; }, }); return directoryUrl;}We tested this verifier against the signer in the previous section. A signed request verified, and the second request reused the cached directory. Replaying the same headers on another path, method, or host failed, as did swapping Signature-Agent to another trusted origin and signing with a key from an untrusted origin. The verifier also loaded the live Google and ChatGPT directories, including Google's short kid values.
What does a verifier need to get right?
Each line in the preceding verifier exists to close a specific gap. The following rules apply to any verifier, in any language:
- Trust a fixed list of directories.
Signature-Agentis a URL chosen by whoever sent the request. If your server fetches any URL that a header names, every request can make your server send a request to a host the attacker picks, a server-side request forgery (SSRF) risk. Fetch only directories that you've decided to trust. - Look keys up by directory and key ID together. The draft says this explicitly. A verifier that indexes keys by
keyidalone can verify a request with a key learned from one operator and attribute it to another. - Require an origin, not a path. For the default
directorytype, theSignature-Agentvalue must be a bare HTTPS origin. Reject paths, query strings, and credentials. The draft allows a port, but you can refuse ports and IP addresses in your own allowlist, as Arcjet does. - Don't follow redirects, and check the response. Require
200and the directory media type. A redirect can move the fetch to a host that you didn't approve. - Honor cache headers within bounds. Cache keys for the advertised lifetime, cap it, and refetch when a request names a
keyidyou haven't seen. Treatno-storeas no-store. - Honor key validity. Skip keys whose
nbftime hasn't arrived or whoseexptime has passed. ChatGPT's directory sets both. - Enforce the time window. Reject signatures that have expired, whose
createdtime is ahead of your clock by more than a small skew, or whose lifetime exceeds 24 hours. - Fail closed on anything you can't rebuild. If a signature covers a component that your framework doesn't give you byte for byte, treat the signature as unverified. Never verify part of a signature.
A full RFC 9421 implementation is large, and the profile is still changing. That's why the verifier uses a library for parsing and cryptography and keeps the policy in your code.
What doesn't a signature protect against?
A signature proves who sent the request. It doesn't make the request single use. Until expires, anyone who has a copy of the headers can send them again, and the signature still verifies. What the copy can reach depends on the covered components: an @authority-only signature works on any path on that host, while one that covers @method and @path works only for that resource.
Reduce replay risk with the following measures:
- Keep signature lifetimes short. Ask operators that you work with to cover
@methodand@path. - Record nonces if you need single-use requests.
web-bot-authaccepts avalidatecallback where you can check each nonce against a store of seen values. Cloudflare's documentation notes that Cloudflare doesn't validate nonces, so a signature that Cloudflare verifies can still be a replay. - Treat logged signature headers as credentials until they expire. Request logs that keep
Signature,Signature-Input, andSignature-Agentcontain values that someone can replay within the validity window. - Don't treat a signature as authorization. A verified agent is a known client, not a client that's allowed to buy, sign up, or change data. Authorize those actions separately.
Why does verification fail behind some frameworks?
Verification fails when your server can't rebuild the request exactly as the agent signed it. The usual cause is @path. RFC 9421 signs the path as it appeared on the wire, percent-encoding and all. Many frameworks hand your code a parsed URL that decodes escapes or resolves dot segments, so /a/%2E%2E/b becomes /b, and the rebuilt signature base no longer matches.
The other causes are infrastructure. A reverse proxy that strips the Signature headers, rewrites the Host header, or mounts your app under a path prefix changes what your server sees. If legitimate signed traffic fails, compare what the agent sent with what reaches your code. OpenAI's guide gives the same advice: confirm that proxies preserve Signature, Signature-Input, and Signature-Agent.
How does Arcjet support Web Bot Auth?
Arcjet verifies Web Bot Auth signatures as part of the detectBot rule. You list the signed agents that you want to allow or deny, next to the crawlers and categories that you already use, and Arcjet Cloud does the parsing, key discovery, caching, and verification. You don't run a directory fetcher, maintain a key cache, or write signature parsing code.
Verification adds identity to the bot decision without replacing anything. The User-Agent, IP address, reverse DNS, and Advanced Signals checks still run on every request, so an unsigned request gets the same protection that it did before.
Configure signed agents in JavaScript
The following Express app allows search engines, ChatGPT's agent, Google's agent, and one custom signer, and denies every other detected bot:
import arcjet, { detectBot } from "@arcjet/node";import { isSpoofedBot, isVerifiedBot } from "@arcjet/inspect";import express from "express";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ detectBot({ mode: "LIVE", allow: [ "CATEGORY:SEARCH_ENGINE", "CHATGPT_AGENT", "GOOGLE_AGENT", "SIGNED_BOT:https://bot.example.com/.well-known/http-message-signatures-directory", ], }), ],});
const app = express();
app.get("/products/:id", async (req, res) => { const decision = await aj.protect(req);
if (decision.isDenied() || decision.results.some(isSpoofedBot)) { res.status(403).json({ error: "Forbidden" }); return; }
if (decision.results.some(isVerifiedBot)) { // A crawler or signed agent that this rule allows by name. }
res.json({ id: req.params.id });});
app.listen(3000);isVerifiedBot is true when Arcjet confirmed a named bot, whether by IP address, reverse DNS, or a signature. Use it to decide what an allowed client gets, after you check isDenied().
Configure signed agents in Python
The Python SDK takes the same IDs. The following FastAPI route allows search engines, ChatGPT's agent, and a custom signer:
import os
from arcjet import BotCategory, Mode, arcjet, detect_bot, is_spoofed_botfrom fastapi import FastAPI, Requestfrom fastapi.responses import JSONResponse
aj = arcjet( key=os.environ["ARCJET_KEY"], rules=[ detect_bot( mode=Mode.LIVE, allow=[ BotCategory.SEARCH_ENGINE, "CHATGPT_AGENT", "SIGNED_BOT:https://bot.example.com/.well-known/http-message-signatures-directory", ], ), ],)
app = FastAPI()
@app.get("/products/{product_id}")async def product(request: Request, product_id: str): decision = await aj.protect(request)
if decision.is_denied() or any(is_spoofed_bot(r) for r in decision.results): return JSONResponse({"error": "Forbidden"}, status_code=403)
return {"id": product_id}Configure signed agents in Go
The Go SDK takes the same IDs in BotOptions:
aj, err := arcjet.NewClient(arcjet.Config{ Rules: []arcjet.Rule{ arcjet.DetectBot(arcjet.BotOptions{ Mode: arcjet.ModeLive, Allow: []string{ arcjet.BotCategorySearchEngine, "CHATGPT_AGENT", "SIGNED_BOT:https://bot.example.com/.well-known/http-message-signatures-directory", }, }), },})if err != nil { return err}
http.HandleFunc("GET /products/{id}", func(w http.ResponseWriter, r *http.Request) { decision, _ := aj.Protect(r.Context(), r)
if decision.IsDenied() || decision.IsSpoofedBot() { http.Error(w, "Forbidden", http.StatusForbidden) return }
w.Write([]byte("ok"))})Trust a signer that isn't in the catalog
To trust any operator that publishes a key directory, add SIGNED_BOT: followed by the operator's directory URL to an allow or deny list. You don't wait for a vendor to add the bot to a list or for the operator to register with your provider. Your rule is the registration.
The URL must be the canonical directory of a public HTTPS origin: https:// plus a lowercase hostname and the exact path /.well-known/http-message-signatures-directory, with no port, IP address, credentials, query string, or fragment. Arcjet Cloud also refuses reserved names such as localhost and hosts under .internal or .test. The SDKs check only the scheme, path, query string, and fragment, so an entry with a port or an IP address passes the SDK, but Arcjet Cloud never fetches it and that signer never verifies. Arcjet Cloud fetches only directories from the catalog and directories that one of your active rules names, and it doesn't follow redirects. A request header can't point Arcjet at any other URL, which closes the SSRF risk described earlier.
If you write a SIGNED_BOT: entry for a directory that's already in the catalog, such as ChatGPT's, Arcjet treats it as the catalog ID. Both forms match the same requests.
How does a verified signature change the decision?
After a signature verifies, Arcjet treats the operator as a named bot, and your rule applies to it the same way that it applies to Googlebot. The following rules follow from that:
- An allow list that names the operator allows it. This includes a category that contains it. For example,
CATEGORY:SEARCH_ENGINEallows a verified DuckAssistBot. - An allow list that doesn't name the operator denies it. A verified signer is a bot. If your rule allows only search engines, a verified Browserbase session is denied, even though its Chrome
User-Agentalone would have passed. Add the signers that you want before you rely on an allow list. - A deny list that names the operator denies it. This works even when the request looks like an ordinary browser.
- An explicitly allowed signer skips the Advanced Signals cookie check. An agent that you allow by name isn't denied for lacking a browser-signals cookie, unless another check denies it.
- A signature doesn't cancel independent evidence. If the IP address or another named bot match denies the request, the decision stays denied.
- A verified signer may omit
User-Agent. Unsigned requests without aUser-Agentheader still produce an error result.
How does Arcjet keep verification fast?
Arcjet keeps key discovery out of the request path wherever the operator allows it. Arcjet Cloud caches each directory for as long as its Cache-Control header allows, up to one hour, and refreshes it before it expires. When a request names a signer whose keys aren't cached yet, Arcjet fetches them in the background and decides that request with the other bot signals. For a directory that allows caching, the request doesn't wait on the operator's server. Later requests verify from the cache.
Some directories forbid caching, and ChatGPT's is one of them. Once Arcjet learns that a directory sends no-store, it reads that directory during each signed request, with a 150 millisecond limit, and shares the result only among signatures on that same request. All key lookups for one request share a 250 millisecond budget. If an operator's server is slow or down, the signature stays unverified and the request falls back to the other checks rather than hanging.
Plan for one consequence of this design. A signed request that arrives before Arcjet has fetched the operator's directory isn't verified yet, whether or not the directory allows caching. Don't make a signature the only way that a request can succeed on the first try. Signatures work best for adding trust to traffic that already gets through your other rules, or for removing friction for an agent that you've chosen to allow.
Which frameworks preserve the signed path?
Signatures that cover only @authority, such as the DuckAssistBot request in this guide, verify with every Arcjet SDK adapter. Signatures that cover @path, such as ChatGPT's, need the original request path. Some adapters can supply it, and the following table shows which:
| SDK | Supplies the original path | Doesn't supply it |
|---|---|---|
| JavaScript | Node.js ( | Next.js App Router and middleware, Bun, Deno, SvelteKit, Remix, React Router, Astro, and Nuxt |
| Python | ASGI frameworks such as FastAPI and Starlette, through
| Django under ASGI, and WSGI servers that set neither variable |
| Go |
|
|
When an adapter can't supply the original path, Arcjet treats a signature that covers @path as unsupported and decides the request with the other bot signals. It never guesses the path from a parsed URL, because a guess that matches would verify a request that wasn't signed.
The same fallback applies to components that Arcjet doesn't rebuild at all: the query string (@query and @query-param), @target-uri, @scheme, @request-target, and the body digest (content-digest). Arcjet verifies signatures that cover @authority, @method, @path, signature-agent, and ordinary request headers.
What policy fits a verified agent?
A verified signature answers who sent the request. What that client may do is still your decision, and the right answer depends on the route. Identification without a per-route policy gives a named client the same access as everyone else.
The following defaults work for most applications:
- Allow verified agents on public content. This includes docs, product pages, pricing, and support articles. An agent that's researching on a person's behalf reads the same pages that the person would.
- Keep rate limits on. A verified operator can still send too many requests. Combine
detectBotwith a rate limit so that a trusted name doesn't come with an unlimited budget. - Decide on signup, login, and checkout separately. Allowing an agent to read your catalog doesn't mean allowing it to create accounts or place orders. If you support agent purchases, authorize them with your own checks, not with the signature alone.
- Deny platforms that you don't want. A deny list with
BROWSERBASEblocks verified Browserbase sessions without blocking every Chrome user on a cloud network. - Start in
DRY_RUN. Measure how many signed requests each route receives and what your rules would have done, then switch toLIVEroute by route. - Keep your other checks. Not every request from a signing operator is signed. IP and reverse DNS verification,
User-Agentmatching, and Advanced Signals cover the rest.
For more information about writing policy for inbound agents, see AI agent bot management. To start verifying signed agents in your own app, follow the Arcjet bot protection quick start and add the bot IDs from this guide to your detectBot rule.
Frequently asked questions
What is Web Bot Auth?
Web Bot Auth is a way for bots and AI agents to prove who sent an HTTP request. The operator publishes public keys on its own domain and signs each request with the matching private key. Your server verifies the signature against those keys.
Which headers does a Web Bot Auth request carry?
A signed request carries Signature-Agent, which names the operator's origin, Signature-Input, which lists the signed components and parameters with the tag web-bot-auth, and Signature, which carries the signature bytes.
Is Web Bot Auth a standard?
The signature format is RFC 9421 HTTP Message Signatures, a published IETF standard. The Web Bot Auth profile on top of it, which defines the components, the Signature-Agent header, and the key directory, is an IETF working group draft.
Which AI agents sign their requests?
ChatGPT's agent, Google's Google-Agent, DuckDuckGo's DuckAssistBot, Browserbase, and Manus publish key directories and sign requests. Arcjet verifies them as CHATGPT_AGENT, GOOGLE_AGENT, DUCKASSIST_BOT, BROWSERBASE, and MANUS_BOT.
Does a valid signature mean I should allow the request?
No. A valid signature tells you which operator sent the request, not what it may do. Decide per route, keep rate limits on, and authorize signups, logins, and purchases with your own checks.
Can Arcjet verify a signer that isn't in its catalog?
Yes. Add SIGNED_BOT: followed by the operator's directory URL, such as SIGNED_BOT:https://bot.example.com/.well-known/http-message-signatures-directory, to the allow or deny list of detectBot. Arcjet fetches only directories that the catalog or one of your active rules names.
Does Web Bot Auth verification add latency?
Arcjet caches each key directory for as long as its Cache-Control header allows, up to one hour, and refreshes it in the background. Once Arcjet learns that a directory forbids caching, it reads that directory during each signed request with a 150 millisecond limit. If a directory is slow or down, or Arcjet hasn't fetched it yet, Arcjet decides the request with the other bot signals.
Why does a valid signature fail to verify in my framework?
Signatures that cover @path need the original request path. Adapters that receive only a parsed URL, such as the Next.js App Router, can't supply it, so Arcjet treats those signatures as unsupported rather than guessing the path.
Application security in your code
Protect your application with Arcjet
Classify the client inside the request handler, where the route, the user, and the business operation are all in scope.