Skip to content

Engineering

Why our agent browsers sign every request: Web Bot Auth in practice

Our agent browsers don’t pass as people: every request carries a Web Bot Auth signature a site can check. How it works, what our edge sees, two gaps we found on our own side, and what a signature can’t tell you.

By the OpenWAF security team

9 min read

Share

In short. Every browser that OpenWAF Browsers runs signs its requests with Web Bot Auth by default. A site can check, with a public key, that an agent is visiting and which service runs it, instead of guessing from a User-Agent string.

  • We don’t sell the other path: no stealth patches, no residential proxies, no CAPTCHA solving.
  • Writing this article turned up two gaps on our own side. Our edge didn’t verify the dictionary form of Signature-Agent that the latest draft asks signers to use, and it didn’t recognise our own browsers. Both are fixed.
  • Until 30 October one key signed for every customer. Now each organization signs with its own key in the same directory. What that changes.
  • A signature says who runs the agent, not what the user allowed it to do. For that we use scoped grants and approval of the exact action.

Declared or disguised

An agent that browses the web has two options. It can pass as a person: patch the browser so automation doesn’t show, rent residential addresses, pay a service to solve CAPTCHAs. Or it can say what it is and let each site decide.

The first path is an arms race, and it keeps getting more expensive. We see it from the defending side. In a recent red-team round against our own challenge, OpenWAF Verify, a script that ran the challenge code in a simulated DOM got through the checkbox. A layout check stopped that, and a headless browser without real user input now stays at the box. Each round costs the attacker more work, and the next round will cost more again. We wrote about the general picture in what stealth browsers hide and what they can’t.

The second path is cheap and stable, provided the site can trust the declaration. A User-Agent string can’t carry that trust, because anyone can copy it. On openwaf.dev, between 27 and 30 October, our edge saw requests that called themselves Twitterbot, PerplexityBot or Claude-User but came from outside those operators’ known networks. A signature can carry it.

How Web Bot Auth works

Web Bot Auth is an IETF working-group draft, draft-ietf-webbotauth-httpsig-protocol, built on HTTP Message Signatures (RFC 9421). It has three pieces:

  1. A key directory. The operator publishes its Ed25519 public keys as a JSON Web Key Set at /.well-known/http-message-signatures-directory. Ours is at https://browsers.openwaf.dev/.well-known/http-message-signatures-directory, and its response is itself signed with each key it lists.
  2. Signature-Agent. Each request names the URL where the keys live. That URL, once a verifier has fetched it and found the key, is the agent’s identity.
  3. The signature. Signature-Input says what is signed: here the host of the request (@authority) and the Signature-Agent header, with a creation time, a 5-minute expiry, a nonce, the key id and the tag web-bot-auth. Signature carries the Ed25519 signature.

This is a real request from one of our browsers, as httpbin.org received it on 30 October:

Signature-Agent: "https://browsers.openwaf.dev"
Signature-Input: sig1=("@authority" "signature-agent");created=1793349067;keyid="3Fv4pDBAod1k9Ru7dIA3ybaqELttB6XvGgaBod4pC0g";alg="ed25519";expires=1793349367;nonce="pkllfk2djUTdpjAU/KKSiYZnTzIe16BKU7iKhM86vkI=";tag="web-bot-auth"
Signature: sig1=:GMouYptYOoeo54tr6UIJpJ9INrt+64vqQoTMBY6wbKyApkCJAsdYmSN8NSRDQnCeZuAuc56i2ZAY6L66WQxPCQ==:

Chrome’s own headers and User-Agent are left as they are. The signature is added inside the browser, so it covers HTTPS requests too.

Checking it takes a few lines. This Node function handles exactly the form above; a library also handles other components, the newer header form and nonce tracking:

import { createPublicKey, verify } from "node:crypto";

// keys: the "keys" array of the agent's directory (fetch it once and cache it)
export function verifyWebBotAuth(req, keys, now = Date.now() / 1000) {
  const agent = req.headers["signature-agent"]; // "https://browsers.openwaf.dev", quotes included
  const [, label, params] = req.headers["signature-input"]?.match(/^([a-z0-9_.*-]+)=(.*)$/) ?? [];
  if (!params?.startsWith('("@authority" "signature-agent");')) return false;
  const p = Object.fromEntries([...params.matchAll(/;(\w+)=("?)([^;"]*)\2/g)].map((m) => [m[1], m[3]]));
  if (p.tag !== "web-bot-auth" || p.alg !== "ed25519" || +p.expires < now || +p.created > now + 60) return false;
  const jwk = keys.find((k) => k.kid === p.keyid);
  const sig = req.headers.signature?.match(new RegExp(`^${label}=:([A-Za-z0-9+/=]+):$`))?.[1];
  if (!jwk || !sig) return false;
  const base = `"@authority": ${req.headers.host.toLowerCase()}\n"signature-agent": ${agent}\n"@signature-params": ${params}`;
  return verify(null, Buffer.from(base), createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(sig, "base64"));
}

We ran it against the request above with the published key: it verifies inside the signature’s five minutes and fails after them or for any other host. host must be the host the client asked for, without a default port; behind a proxy that rewrites it, use the original.

What a site sees

openwaf.dev sits behind our own edge, which classifies every request before the site sees it. From 27 to 30 October it classified 23,643 requests:

ClassRequests
Known bots, verified by IP range, reverse DNS or signature (Googlebot, ClaudeBot, GPTBot, OAI-SearchBot, ChatGPT-User and others)904
A known bot’s name from outside its operator’s known networks (Twitterbot 6, PerplexityBot 1, Claude-User 1)8
Unverified automation, most of it our own monitoring and tooling11,253
Browsers that look like people11,478

Only three of those requests were from an operator that signs: ChatGPT-User, verified by OpenAI’s published ranges and Web Bot Auth. Signing is still rare, which is why a site still needs the other checks.

The same report showed us a gap of our own. Our edge’s bot catalog listed the Web Bot Auth directories of ChatGPT and Browserbase, not ours. Until 30 October a signed request from OpenWAF Browsers reached a site behind our edge as a signature from an unknown key, and was counted as unverified automation. We added our directory to the catalog, with a test built from a real captured request. Then we opened a real browser session against openwaf.dev: the report showed its 6 requests as OpenWAF Browsers, all verified by Web Bot Auth.

The interop gotcha

The header has two forms. The draft that Cloudflare documents, and that most signers send today, makes Signature-Agent a quoted string, signed as the component "signature-agent". Later drafts changed it to a dictionary keyed by the signature label, signed as one member:

Signature-Agent: sig1="https://browsers.openwaf.dev"
Signature-Input: sig1=("@authority" "signature-agent";key="sig1");…

The current draft, -01 of 29 October 2026, says signers must send the dictionary form, and verifiers may still accept the string. Cloudflare’s Web Bot Auth documentation (last updated 8 October 2026) says its verifier implements the string form and fails a message that uses the dictionary form or the key component parameter. Until this week, so did ours.

What we did:

  • Our edge now accepts both forms. It supports the key parameter (RFC 9421 §2.1.2) and attributes a request only to the member its signature covers. Unsigned members, members with parameters and a key that points to a missing member are ignored or rejected. The draft’s own test vector is part of the test suite. The change is rolled out to every edge node.
  • Our browsers keep sending the string form. A site behind Cloudflare is the most likely verifier our customers’ agents meet, and a signature that fails there helps nobody. We will switch when Cloudflare documents support for the dictionary form.

If you write a verifier, accept both. The two forms are easy to tell apart: a string starts with a double quote and a dictionary key doesn’t.

One key per organization

Until 30 October every OpenWAF Browsers session, whichever customer started it, signed with the same key under the same Signature-Agent; the captured request above is one of them. A site that trusted the signature trusted the whole service. It couldn’t tell customers apart, so if one customer misused the browsers, a site might block all of them.

Since 30 October each organization signs with its own Ed25519 key, in the same directory and under the same Signature-Agent. The keyid in Signature-Input tells one organization’s browsers from another’s without naming the organization, so a site can rate-limit or block one customer by key id, and an abuse report can be traced to one organization. A newly approved organization signs with the shared key for about ten minutes while its own key is published. Withdrawing an organization’s access removes its key from the directory, and our own edge stops accepting it within a minute: in our test, a request signed with the key before it was withdrawn was rejected 51 seconds after.

The controls on our side stay:

  • Access is approved per organization. Browsers stay off until we enable them for an organization.
  • Quotas. 20 browser-hours and 5 GB of egress a month by default, 2 concurrent browsers per key, 60 minutes per session.
  • A switch per key and a general one. Disabling a key stops its browsers and cuts their egress at the next request; the kill switch does the same for the whole service.
  • An egress log of every request (host, decision, bytes, time) to handle abuse reports, and an acceptable use policy that forbids passing the agent off as a person, with or without a signature.

The draft also allows two other designs: a separate directory URL per customer, since the identifier is the URL a verifier resolves, and two signatures on one request, where the customer’s agent signs with its own key and the remote browser signs as well (Appendix D.2). We haven’t built either.

What a signature doesn’t solve

A valid signature proves that a holder of a key the URL publishes signed the request. The draft itself is explicit: it says nothing about whether the agent is benign or whether the request is authorized. It authenticates the agent, not the intent behind a given request.

That question came up in a public discussion about AI browser agents passing as humans, and we think it splits into three parts:

  • Who runs the agent. The signature answers this, for the site.
  • What the user allowed it to do. A grant from the user: scoped to some sites and actions, and revocable.
  • Whether the user wants this specific action. A confirmation of that action, not of a vague description of it.

OpenWAF Tasks implements the last part. A task browses read-only; when it wants to write, it proposes a list of steps, and a person approves those exact steps. The approval carries the SHA-256 of the steps, so if anything changed after the person looked at it, the approval is refused, and the runner replays the approved steps itself, without the model. The approvals docs describe the whole flow.

Get started

  • If you run a site: the Web Bot Auth page shows what our browsers send and how to verify it. Behind OpenWAF, AI Crawl Control already does it, and our browsers appear as openwaf-browsers in the reports and the per-bot overrides.
  • If you build agents: the quickstart starts a browser in five minutes, and what it is not lists what we won’t do and why.

Frequently asked questions

What is Web Bot Auth?

An IETF draft that lets a bot or agent sign each HTTP request with a key it publishes, using HTTP Message Signatures (RFC 9421). A site checks the signature against the key directory named in the Signature-Agent header, instead of trusting a User-Agent string.

Can I turn signing off in OpenWAF Browsers?

Yes, per browser or as the organization’s default, but we recommend keeping it on. Turning it off doesn’t make the browser stealthy: it is still Chrome as shipped, from OpenWAF’s addresses.

Which Signature-Agent form should a verifier accept?

Both. The current draft asks signers for the dictionary form, while Cloudflare’s verifier and most signers use the quoted string. Our edge accepts both; our browsers send the string until Cloudflare documents support for the dictionary.

Does a valid signature mean the request is authorized?

No. It says which service runs the agent. What the user allowed it to do needs a scoped, revocable grant, and a write can need a person’s approval of the exact action, as in OpenWAF Tasks.

Can a site tell OpenWAF customers apart?

Yes, by the keyid. Since 30 October each organization signs with its own key in the same directory, under the same Signature-Agent, so a site can rate-limit or block one organization without affecting the others. Before that, one key signed for every customer.

Sources

Topics

  • Web Bot Auth
  • HTTP Message Signatures
  • RFC 9421
  • Signature-Agent
  • AI agents
  • agent browsers
  • bot verification
  • Ed25519
  • OpenWAF Browsers
  • OpenWAF Tasks
  • AI Crawl Control
  • Cloudflare

Author

OpenWAF

Keep reading

View all

Bring your agency’s sites into early access.

OpenWAF is free while it is in early access. Create your account, add your clients’ domains and switch their nameservers or add a CNAME: the dashboard guides you through each step.