Web Bot Auth from the verifier's side: what a signature covers

A Web Bot Auth signature covers only the components that the agent lists in Signature-Input. The default set in Cloudflare’s web-bot-auth library is two: @authority and signature-agent. On my verifier, one signature made for /a also verified on /b. The verifier proves which key signed. The agent decides what that key signed.

How I measured it

I wrote the fastify-web-bot-auth plugin in August. That essay is about why the check belongs at the origin. This log is about what the check does with each request, measured on 2026-11-09.

The setup: version 0.1.2 of the plugin, web-bot-auth 0.1.3, Fastify 5.12.1, Node 24.13.0. The signer used the RFC 9421 Appendix B.1.4 Ed25519 test key. Cloudflare Research publishes the public half of that key at /.well-known/http-message-signatures-directory on its test origin, so every verification below fetched a real directory over the network. The directory was 178 bytes and held one key. The package suite passed 93 of 93 tests on the same checkout, with 97.77% line coverage.

What the agent sends

This is one signed request, as the library builds it by default:

Signature-Agent: "https://http-message-signatures-example.research.cloudflare.com"
Signature-Input: sig1=("@authority" "signature-agent");created=1794218400;
  keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";
  expires=1794218460;nonce="/9Z47Iz...";tag="web-bot-auth"
Signature: sig1=:MDl+hnneWu2M8QeLjhqW5IviyCrer9fQ3lo6ndNfuHCOmbrG6bSJVU19aHInexzHY7UgLRHOp0H/YpOa/9WbBg==:

The list in parentheses is the signature base. The method, the path, the query and the scheme are not in it. RFC 9421 lets the signer choose the components, and the verifier can only check the ones the signer chose.

A signature for one path verifies on every path

I signed a request for /a and sent it to /b. The verdict was verified: true. Then I sent a signature made for /elsewhere to a route in enforce mode. The route returned 200, and the handler ran.

That is correct behaviour, not a bug in the verifier. Nothing in the signature names a path, so there is nothing to compare. When I signed with ("@authority" "@path" "signature-agent"), the same test failed as it should: /a verified and /b returned reason: 'bad-signature'.

Add the plugin’s own limit from version 0.1.2: it keeps no replay store. A default signature is a bearer credential for the whole host, for the length of its window. In my run the window was 60 seconds. Treat a verified verdict as “this agent sent a request to this host recently”, not as “this agent asked for this resource”.

Which changes break a signature

The signature breaks only when a covered value changes. I tested five changes:

Change to a signed request Verdict
Different path verified
Signed as https, received as http verified
Host rewritten by a proxy to origin.internal:3000 bad-signature
Signature-Agent sent bare instead of quoted bad-signature
One bit of the signature flipped bad-signature

The fourth row surprised me. The Signature-Agent value is itself a covered component, so the exact bytes matter. The plugin accepts both the quoted and the bare form when it parses the origin. But if a proxy normalises the quotes away, the signature base changes, and the verdict is bad-signature.

The second row matters for anyone behind a TLS-terminating proxy. Fastify sees http, but the default signature does not cover the scheme, so that alone does not break it. The Host rewrite does. If every signed request fails as bad-signature behind a proxy, look at Host first.

What the clock-skew window allows

The plugin has a clockSkew option, 60 seconds by default. I sent a signature that had expired 1 second earlier. The verdict was expired. I set clockSkew: 300 and sent it again. The verdict was still expired.

The library checks created <= now <= expires strictly before the plugin’s own checks run. So clockSkew cannot widen the window. It can only matter inside it. A signature with created 5 seconds in the future also returned expired, not malformed. If your verdicts show expired for an agent that signs correctly, compare the two clocks before you change the option.

What each failure costs the server

The first verification of an agent fetches its directory. In my run that took 243 ms in total, and 239 ms of it was the fetch. After that the key came from the cache: 50 more signed requests had a median elapsedMs of 0 and a maximum of 1, with no new fetches.

The failure paths are where a careless verifier spends money:

Without the throttle and the negative cache, an attacker could turn each request into one outbound fetch from my server. With them, the cost is one fetch per origin per 30 seconds.

What a bad signature returns

In observe mode, nothing is blocked. The request continues with a verdict on request.webBotAuth, and the route decides. On a route in enforce mode, an unverified request gets a 401 with a small JSON body:

{ "statusCode": 401, "error": "Unauthorized", "reason": "unsigned" }

The reason is one of six values. A verified agent that your trust policy rejects gets a 403 with reason: 'untrusted' instead. I kept the 401 body this small on purpose: the agent learns which check failed, but not which keys I hold or how my cache behaves.

What I do not know yet

My production crawler log records the user agent, the path, the IP and the IP-range verdict for most requests: 7,550 records to 2026-10-28. It does not record Signature-Agent. So I cannot say how many signed agents have reached this site, or which components real agents sign. Until I log that header, the measurements above describe the verifier, not the traffic.

Revisions

  1. Created from a measurement run that day: fastify-web-bot-auth 0.1.2 with web-bot-auth 0.1.3 and Fastify 5.12.1 on Node 24.13.0, verifying against Cloudflare Research's live key directory.
  2. Published on its Nov 9 slot; auto-drafted by the publishing run because no draft existed for the row.