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

A Web Bot Auth signature covers only the components the agent lists in Signature-Input. The default set in Cloudflare's web-bot-auth library is two: @authority and signature-agent. So a valid signature made for one path also verifies on every other path of the same host until it expires. The verifier proves which key signed; the agent decides what the key signed.

Published: 2026-11-09
Canonical: https://umar.codes/web-bot-auth-verifier

## Revisions

- 2026-11-09 — 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.
- 2026-11-09 — Published on its Nov 9 slot; auto-drafted by the publishing run because no draft existed for the row.

---

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`](/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:

```http
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.1` and the
  IPv4-mapped `https://[::ffff:7f00:1]` all returned `malformed` before 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:

```json
{ "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.
