Copy the signed request from Visa's Trusted Agent Protocol spec into a unit test, run it through an Ed25519 verifier, and it fails. Mastercard's Agent Pay example fails too. Both reuse a signature from an IETF test vector that signs a different message, and both set a 60-minute validity window that breaks the 8-minute limit each network tells merchants to enforce.
Web Bot Auth, Visa TAP and Mastercard Agent Pay are how an AI agent proves who it is to a merchant's website in 2026. All three sit on RFC 9421 HTTP Message Signatures, and they disagree on tags, windows, nonces and the syntax of a single header. This guide shows a real signed request, the signature base a verifier rebuilds, and verification code that copes with all three.
What Is Web Bot Auth?
Web Bot Auth gives bots and AI agents a cryptographic identity at the HTTP layer. The agent operator signs each request with a private key and publishes the public key in a directory. The site verifies the signature and knows which operator sent it, with no shared secret and no change to TLS.
It is still a draft. The IETF chartered the webbotauth working group on 23 October 2025, and its only adopted document is draft-ietf-webbotauth-httpsig-protocol-00, published 1 September 2026 by authors from Cloudflare and Google. Adoption was contested: the poll at IETF 126 came out 22 yes, 6 no, 5 no opinion.
Deployment has run ahead of the standard anyway. Cloudflare, Akamai, AWS WAF, Vercel and HUMAN verify signatures; ChatGPT's cloud browser, Google-Agent and AWS AgentCore Browser send them. On 23 October 2026 IANA registered ml-dsa-44, ml-dsa-65 and ml-dsa-87 for HTTP Message Signatures, though Cloudflare and AWS WAF still accept Ed25519 only.
How Does Web Bot Auth Work? A Signed Request, Line by Line
These headers are the current draft's own example (Appendix E.2.1), with line wrapping removed:
Signature-Agent: agent2="https://signature-agent.test"
Signature-Input: sig2=("@authority" "signature-agent";key="agent2");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=4889289600;nonce="n9p433xm+NJ3ph3upfBIGmsuwHw387YV7Q/F+6BSpGCVjYCqQw6rznNA8PVVLySrAWsv0hQtFioQb6E1YsauiA==";tag="web-bot-auth"
Signature: sig2=:RdNFx5Bj6au3YgAMQL/RzmUlZE8QZLIaXGRpw985hWnwPfMxT228NMk6ehRS1PSl4e8PhbNZACSanGdhEwYCCg==:
The verifier does not check the headers directly. It rebuilds a signature base, one line per covered component, with @signature-params required as the last line:
"@authority": example.com
"signature-agent";key="agent2": "https://signature-agent.test"
"@signature-params": ("@authority" "signature-agent";key="agent2");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=4889289600;nonce="n9p433xm+NJ3ph3upfBIGmsuwHw387YV7Q/F+6BSpGCVjYCqQw6rznNA8PVVLySrAWsv0hQtFioQb6E1YsauiA==";tag="web-bot-auth"
Those bytes verify against the RFC 9421 Appendix B.1.4 Ed25519 test key. I checked. The key is public, so the vector is for tests only.
What the draft requires of a signer:
- Coverage: at least one of
@authorityor@target-uri, plus theSignature-Agentmember that belongs to this signature. - Parameters:
created,expires, akeyidthat "MUST be a base64url JWK SHA-256 Thumbprint", andtagset toweb-bot-auth. - Expiry: "RECOMMENDED that expiry be no more than 24 hours".
- Nonce: no requirement. Earlier versions asked for a unique 64-byte nonce; the WG draft dropped it.
- Algorithms: anything registered except shared-secret HMAC, which is banned.
- Keys: a JWKS at
/.well-known/http-message-signatures-directory, media typeapplication/http-message-signatures-directory+json, HTTPS only. Failed lookups may be cached for at most five minutes. There is no revocation.
@authority can be reused "against any method, path, or body sent to that authority until it expires". Combine that with a 24-hour expiry and one captured header is a day of access to your whole host.
Why Signature-Agent Has Three Formats
The header has had three syntaxes: a bare hostname in the first drafts, then a quoted string, and now a structured-field dictionary. The current draft says signers "MUST send the dictionary form" and verifiers "MAY" accept the old string. In the wild:
| Sender | Signature-Agent value | Form |
|---|---|---|
| ChatGPT cloud browser | "https://chatgpt.com" | Legacy string |
| Shopify (merchant-issued) | "https://shopify.com" | Legacy string |
| Google-Agent | g="https://agent.bot.goog" | Dictionary |
| AWS docs example | https://signature-agent.test (unquoted) | Neither |
The two valid forms are distinguishable on the wire: a string starts with ", a dictionary key does not. Your parser must accept both. Google has a second quirk: its directory uses six-character key IDs such as DYiMjA, which are not JWK thumbprints, so a verifier that enforces the thumbprint rule rejects them.
Web Bot Auth vs User-Agent, Reverse DNS and IP Allowlists
| Method | What it proves | Where it fails |
|---|---|---|
| User-Agent string | Nothing | Anyone can send ChatGPT-User |
| Reverse plus forward DNS | The IP maps to a hostname the operator controls | Only for operators with their own IP space |
| Published IP lists | The request left the operator's ranges | Shared cloud IPs; Google's user-triggered fetcher list includes gae.googleusercontent.com addresses used by GCP customers' own sites |
| mTLS | A client certificate at the TLS layer | Terminates at your CDN; the origin never sees it |
| Web Bot Auth | The operator's key signed this host recently | Replay inside the window, header stripping, no revocation |
Keep IP checks as a fallback. Google says plainly "We don't sign every request", OpenAI's crawler page still documents IP lists only (chatgpt-user.json held 234 prefixes when regenerated on 20 October 2026), and Anthropic documents only IP ranges for its fetchers.
What Is Visa's Trusted Agent Protocol (TAP)?
Visa launched TAP on 14 October 2025, built with Cloudflare, naming 12 partners including Adyen, Checkout.com, Cybersource, Shopify, Stripe and Worldpay. TAP is a stricter profile of RFC 9421 plus two signed JSON objects in the request body.
Header rules from the spec:
- Tags:
agent-browser-authfor browsing (product pages, availability, final price) andagent-payer-authfor checkout. A request with neither "has not been signed by a trusted agent". - Coverage:
@authorityand@path, so a signature cannot be replayed against another URL on your site. - Window:
createdandexpires"should not be more than 8 minutes apart". - Nonce: required, and you block any nonce seen in the last 8 minutes. Several signatures on one request share a nonce, so deduplicate per request, not per signature.
- Keys: fetched from Visa by
keyidas a JWK. Visa says it will host a well-known URL but has not published one.
agenticConsumer carries an OIDC-style idToken with obfuscated email and phone claims, plus contextualData (countryCode, zip, ipAddress, deviceData), signed with PS256. agenticPaymentContainer changes with the checkout type: a paymentCredentialsHash when the agent keys a card into your form, a browsingIOU (invoiceId, amount, cardAcceptorId, acquirerId, uri, sequenceCounter) for HTTP 402 flows, or an encrypted payload for API checkout. If the consumer object fails verification, using it is your call. If the payment container fails, you must not use it, and a hash mismatch against the keyed card means decline.
Governance is thin. The spec page has no version or date, and the GitHub repo holds sample code across six commits, the last on 28 October 2025. Yet the Product Terms require you to apply any Visa update within six months. Save a copy of the page you implemented and diff it monthly.
Visa TAP vs Mastercard Agent Pay vs Web Bot Auth: Compared
Mastercard launched Agent Pay on 29 April 2025 with Agentic Tokens, and its Acceptance Framework (14 October 2025) put agent verification on Web Bot Auth at the CDN, with Cloudflare as partner. Its developer guide uses the tag Agent-pay-auth and Signature-Agent: "https://agentpay-key-directory.mastercard.com/". That hostname does not resolve in DNS today, so treat it as illustrative.
| Web Bot Auth (WG draft -00) | Visa TAP | Mastercard Agent Pay | |
|---|---|---|---|
| Tag | web-bot-auth | agent-browser-auth, agent-payer-auth | Agent-pay-auth |
| Must sign | @authority or @target-uri, plus signature-agent | @authority, @path | @authority, @path |
| Max window | 24 h recommended | 8 min | 8 min |
| Nonce | Not required | Required, 8-min replay cache | Required, 8-min replay cache |
| Signature-Agent | Required, dictionary form | Absent from examples | Legacy string form |
| Key source | Operator's well-known JWKS | Visa key endpoint by keyid | Mastercard key directory |
| Body objects | None | agenticConsumer, agenticPaymentContainer | mastercardIdToken, payment data |
| Payment credential | Out of scope | Visa token via Intelligent Commerce | Agentic Token in standard card fields |
The trade-off: Visa gives you more data (identity token, device context, credential hash) and asks for more code. Mastercard's Level 1 integration promises "minimal changes" by pushing verification to your CDN, then carries the payment in the card fields you already have. One Mastercard rule surprises people: agents "cannot use consumers' existing cards on file with merchants, even when logging a consumer into their merchant account". If you auto-select a saved card for a logged-in agent session, the transaction is not recognised as agentic.
Treat tags as exact strings, and expect them not to line up. The WG draft says the tag "MUST be web-bot-auth", which Visa's and Mastercard's are not. Mastercard's own Node sample accepts only agent-browser-auth and agent-payer-auth, so it rejects Mastercard's tag.
Why the Visa and Mastercard Example Signatures Don't Verify
Visa's browse example, Visa's checkout example and Mastercard's Agent Pay example all carry the same signature, jdq0SqOwHdyHr9+r5jw3...BA==, the same keyid and the same nonce. All three come from Appendix E.2.2 of the IETF draft, a vector that signs @authority plus signature-agent with tag web-bot-auth. Visa and Mastercard changed the covered components and the tag but kept the signature.
An Ed25519 signature is bound to the exact bytes it signed, so a signature over one base will not verify over another. I rebuilt the signature bases for all three examples, with both Ed25519 and ed25519 spellings and both example.com and www.example.com authorities: 12 variants. The draft's vector verifies against the RFC test key. None of the 12 do. Every example also has expires - created of 3,600 seconds, which fails the 8-minute check both networks tell you to run.
Visa's spec also prints its signature base with the label inside the last line ("@signature-params": sig2=("@authority" "@path")...). RFC 9421 does not put the label there; if you build your verifier to match that example, real signatures will fail.
Generate your own vectors from the RFC 9421 B.1.4 test key with Cloudflare's web-bot-auth library (Rust crate 0.7.0, npm 0.2.0) and assert on those. The same habit pays off with PSP webhook signatures, where vendor samples drift from production too.
How to Verify an AI Agent Signature in Your Backend
This handles Ed25519, the algorithm used by Cloudflare, AWS WAF and both networks' examples. Parse Signature-Input and Signature-Agent with a Structured Fields library rather than regex.
import { verify, type KeyObject } from "node:crypto";
import type { RedisClientType } from "redis";
const AGENT_TAGS = new Set(["web-bot-auth", "agent-browser-auth", "agent-payer-auth", "Agent-pay-auth"]);
const NETWORK_MAX_WINDOW = 480; // Visa TAP and Mastercard Agent Pay: 8 minutes
const SKEW = 30;
type Req = { method: string; authority: string; path: string; agentMember?: string };
type Sig = { components: string[]; rawParams: string; created: number; expires: number;
keyid: string; nonce?: string; tag: string; value: Buffer };
export async function verifyAgent(req: Req, sig: Sig, key: KeyObject | undefined,
redis: RedisClientType): Promise {
if (!AGENT_TAGS.has(sig.tag)) return "not-agent-traffic";
const now = Math.floor(Date.now() / 1000);
if (sig.created > now + SKEW || sig.expires < now - SKEW) return "expired";
const network = sig.tag !== "web-bot-auth";
if (network && sig.expires - sig.created > NETWORK_MAX_WINDOW) return "window-too-long";
if (network && !sig.nonce) return "missing-nonce";
if (!key) return "unknown-key";
const lines = sig.components.map((c) => ${c}: ${componentValue(req, c)});
lines.push("@signature-params": ${sig.rawParams}); // exactly as received
if (!verify(null, Buffer.from(lines.join("\n")), key, sig.value)) return "bad-signature";
// Record the nonce only after the signature passes, so junk cannot fill the cache
if (sig.nonce) {
const fresh = await redis.set(agent:nonce:${sig.keyid}:${sig.nonce}, "1",
{ NX: true, EX: NETWORK_MAX_WINDOW });
if (fresh === null) return "replay";
}
return "verified";
}
function componentValue(req: Req, c: string): string {
if (c === '"@authority"') return req.authority.toLowerCase(); // public host, not the origin's
if (c === '"@path"') return req.path;
if (c === '"@method"') return req.method.toUpperCase();
if (c.startsWith('"signature-agent"') && req.agentMember) return req.agentMember;
throw new Error(unsupported component ${c});
}
Three details carry most of the bugs. Use rawParams byte for byte from the header; re-serialising reorders parameters and breaks the base. Key your public-key cache on the pair (directory URL, keyid), as the draft requires, so one operator cannot shadow another's key ID. And SET NX EX checks and records the nonce in one atomic call, which a separate GET then SET does not.
Does Web Bot Auth Work Behind Cloudflare in 2026?
Mostly. Cloudflare verifies Ed25519 only, and its docs say "there is currently no nonce validation", recommending a short expires instead ("A minute is often sufficient"). For a checkout request tagged agent-payer-auth, the nonce is the only thing that stops an 8-minute replay, so check it again at your origin.
Cloudflare's docs also list the dictionary form of Signature-Agent as a failure case. That is the only form the current draft lets signers send, and the form Google already uses. Cloudflare's "signed agent" category was retired on 1 July 2026 and replaced by Direct and Intermediary verified bots, so WAF rules written against the old meaning of cf.bot_management.signed_agent need a review. The cf-verified-bot header that tells your origin what Cloudflare decided is Enterprise-only.
AWS WAF Bot Control labels requests web_bot_auth:verified, invalid, expired or unknown_bot. Behind any proxy, @authority must be the public hostname the agent signed: if your origin sees origin.internal, rebuild it from a forwarded host header set by a proxy you trust. The draft says a proxy "SHOULD NOT strip" the signature headers. Test that yours doesn't.
Do Verified AI Agents Change Chargebacks and SCA in the UK and EU?
No. A valid TAP or Agent Pay signature authenticates the agent's HTTP request to your site. It does not authenticate the cardholder, and neither network ties it to liability.
The Visa Rules (April 2026 edition) never mention TAP. They say an Agentic Payment Provider "is considered to be the Cardholder for an Agentic Transaction" and require providers to enrol in Visa Intelligent Commerce and use Visa tokens. What changed on 24 October 2026 is dispute evidence: Compelling Evidence 3.0 now counts "login IDs for an Agentic Payment Provider" as a matching element. Mastercard's guide says its standard fraud detection, chargeback rights and dispute processes apply to agentic transactions. The reason-code side is unchanged, as covered in Visa vs Mastercard chargeback reason codes.
In Europe, SCA happens at the issuer, not in the signature. Visa's 2 July 2026 announcement covered live transactions with more than 30 European issuers, including Barclays, HSBC UK, Lloyds, Nationwide, NatWest and Revolut, with every payment authenticated by Visa Payment Passkeys. Mastercard enabled all its European issuers at network level on 2 June 2026, but none of the 36 banks it listed as live is in the UK. Plan your UK checkout for passkey step-up, not for the signature to waive it.
My Take: Treat the Signature as a Bot Pass, Not a Payment Credential
This is opinion. The agent signature is an access-control signal for your web tier: let this request past the bot wall and skip the CAPTCHA. It should never be an input to a payment decision on its own. The payment evidence is the token, the issuer's authentication and your CE 3.0 records. Wiring "verified agent" straight into risk rules means trusting a header that Cloudflare does not replay-check and that carries no published liability.
My prediction: by the end of 2027 the network tags fold into tag="web-bot-auth", with a network directory as the Signature-Agent. The WG draft already makes web-bot-auth a MUST, Mastercard's guide lists three tags while its sample accepts two, and the September 2026 Know Your Agent principles from Visa, Mastercard and Ant International promise interoperability without naming a mechanism. Separate tag vocabularies at every CDN are a cost that gets standardised away, much as the payment layer is converging in AP2 vs ACP vs x402.
What This Means for Merchant Engineering Teams
- Accept both
Signature-Agentforms and log which one each operator sends. Reject unquoted values. - Allowlist tags exactly:
web-bot-auth,agent-browser-auth,agent-payer-auth,Agent-pay-auth. Apply the 8-minute and nonce rules to the network tags. - Re-check nonces at the origin for anything tagged for payment, even if your CDN verified the signature.
- Delete fixtures copied from Visa or Mastercard docs and generate vectors from the RFC 9421 test key.
- Keep IP and reverse-DNS checks for Google-Agent and operators that do not sign.
- Stop auto-selecting stored cards for agent sessions on Mastercard, or the transaction loses its agentic flag.
- Store agent evidence for disputes: tag,
keyid, Signature-Agent URL and the agent's login ID, which now counts under CE 3.0. - Read the PSP field, not the header: Stripe's
2026-09-30.previewAPI returnspayment_method_details.card.credentials.typeasfpan,agentic_tokenordpan. That is what your risk engine should use.