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:
- Unknown key. I signed 5 requests with a fresh key that claimed the
Cloudflare origin. All 5 returned
unknown-key. The first forced one directory refresh and took 44 ms. The other 4 hit the 30-second refresh throttle and took 0 ms. Total outbound fetches: 1. - Unreachable directory. I sent 3 requests that named an origin that does
not resolve. All 3 returned
directory-unreachable. The first took 18 ms; the negative cache answered the other 2. Total outbound fetches: 1. - Forbidden origin.
http://example.com,https://127.0.0.1and the IPv4-mappedhttps://[::ffff:7f00:1]all returnedmalformedbefore any fetch.
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
- 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.
- Published on its Nov 9 slot; auto-drafted by the publishing run because no draft existed for the row.