Admit only verified agents
Require Web Bot Auth HTTP message signatures from trusted agents with verifiedAgent() from @tollstile/web-bot-auth, alongside payment.
Goal: GET /weather costs $0.01 and only answers agents that sign their requests with a key published by an origin you trust. Everyone else gets 403 before anything is reserved.
verifiedAgent() answers who is calling, not whether they paid. It is a requirement: it runs after the payer is known and before the ledger is touched, next to any rail or access policy.
Prerequisites
- Node.js 22.18 or later, which runs TypeScript files directly. On older versions, run the files with
npx tsx. - The HTTPS origins of the agents you trust. Each publishes its keys at
https://<origin>/.well-known/http-message-signatures-directory.
npm install tollstile @tollstile/hono @tollstile/web-bot-auth hono @hono/node-server1. Require a signature
import { readFileSync } from "node:fs";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { tollstile } from "@tollstile/hono";
import { verifiedAgent } from "@tollstile/web-bot-auth";
import { createTollstile, memoryLedger, testRail } from "tollstile";
const AGENT = "https://agent.example";
const DIRECTORY = `${AGENT}/.well-known/http-message-signatures-directory`;
// Local development only: serve the agent's key directory from a file instead of the network.
const localDirectory: typeof fetch = (input, init) =>
String(input) === DIRECTORY
? Promise.resolve(
new Response(readFileSync("directory.json"), {
headers: { "content-type": "application/http-message-signatures-directory+json" },
}),
)
: fetch(input, init);
const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });
const agentsOnly = toll.price("$0.01", {
require: [verifiedAgent({ trust: [AGENT], requireNonce: true, fetch: localDirectory })],
});
const app = new Hono();
app.get("/weather", tollstile(agentsOnly), (c) => c.json({ forecast: "clear" }));
serve({ fetch: app.fetch, port: 3000 });In production, drop the fetch option: the directory is fetched from the trusted origin, and only origins in trust are ever fetched.
2. Create an agent key
import { writeFileSync } from "node:fs";
const { publicKey, privateKey } = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"])) as CryptoKeyPair;
const { kty, crv, x } = await crypto.subtle.exportKey("jwk", publicKey);
// What https://agent.example/.well-known/http-message-signatures-directory serves.
writeFileSync("directory.json", JSON.stringify({ keys: [{ kty, crv, x }] }, null, 2));
// The agent's private key. Never publish it.
writeFileSync("agent-key.json", JSON.stringify(await crypto.subtle.exportKey("jwk", privateKey)));3. Sign a request
A minimal RFC 9421 signer for the Web Bot Auth profile. Agents normally use a library for this, such as Cloudflare's web-bot-auth.
import { readFileSync } from "node:fs";
const AGENT = "https://agent.example";
const url = new URL(process.argv[2] ?? "http://localhost:3000/weather");
const privateJwk = JSON.parse(readFileSync("agent-key.json", "utf8")) as JsonWebKey;
const key = await crypto.subtle.importKey("jwk", privateJwk, { name: "Ed25519" }, false, ["sign"]);
// keyid is the RFC 7638 thumbprint: SHA-256 over the public key's required members, in order.
const { crv, kty, x } = privateJwk;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ crv, kty, x })));
const keyid = Buffer.from(digest).toString("base64url");
const created = Math.floor(Date.now() / 1000);
const params =
`("@authority" "@method" "@path" "signature-agent";key="sig1")` +
`;created=${created};expires=${created + 60};keyid="${keyid}";alg="ed25519"` +
`;nonce="${crypto.randomUUID()}";tag="web-bot-auth"`;
// The signature base: one line per covered component, then the signature parameters.
const base = [
`"@authority": ${url.host}`,
`"@method": GET`,
`"@path": ${url.pathname}`,
`"signature-agent";key="sig1": "${AGENT}"`,
`"@signature-params": ${params}`,
].join("\n");
const signature = await crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(base));
const response = await fetch(url, {
headers: {
payment: "test",
"signature-agent": `sig1="${AGENT}"`,
"signature-input": `sig1=${params}`,
signature: `sig1=:${Buffer.from(signature).toString("base64")}:`,
},
});
console.log(response.status, await response.text());4. Verify
node keys.ts
node server.tsnode agent.ts # 200 {"forecast":"clear"}
curl -i localhost:3000/weather # 402: payment comes first
curl -i -H "Payment: test" localhost:3000/weather # 403: paid, but not signedHTTP/1.1 403 Forbidden
{"error":{"code":"requirement_failed","retryable":false,"action":"stop","message":"Requirement \"verified-agent\" was not met.","detail":"signature_missing"},
"resource":"GET /weather","requirement":"verified-agent"}Run node agent.ts a second time and it passes again: each run signs with a fresh nonce. A signature whose covered components do not match the request fails with 403 and error.detail signature_invalid; a key missing from the directory fails with error.detail key_not_found.
Verification status
verifiedAgent() is tested against RFC 9421 and Web Bot Auth draft test vectors and freshly generated ed25519, P-256, and RSA-PSS keys with fake directories. It has not been verified against a live agent. To check one, trust its origin and call your endpoint with its signer: expect 200; change one covered header and expect 403 signature_invalid.
When things fail
| Reason | Status | Meaning |
|---|---|---|
signature_missing, signature_malformed, tag_missing | 403 | No usable Web Bot Auth signature |
signature_expired, signature_too_old, signature_not_yet_valid | 403 | Outside created / expires, maxAgeMs, or clockSkewMs |
agent_untrusted | 403 | The Signature-Agent origin is not in trust; nothing was fetched |
key_not_found, algorithm_mismatch, signature_invalid | 403 | Key selection or cryptographic verification failed |
nonce_missing, nonce_replayed | 403 | Replay protection |
http_request_required | 403 | MCP tool calls are never attributed to a signed HTTP request |
directory_invalid | 403 | The directory answered, but with a redirect, another non-200 status, or malformed or oversized content |
directory_unavailable | 503 | The directory could not be reached and nothing usable is cached. Retry later |
Every denial happens before anything is reserved. A nonce is claimed before the charge is created, so a request whose handler failed cannot be retried with the same signature: agents sign each attempt.
Deployment notes
- Reconstruct the public URL.
@authorityand@pathcome fromrequest.url. Behind a proxy, the adapter must see the URL the agent signed (for example, Expresstrust proxy). - Ask for more than
@authority. A signature over@authorityalone can be replayed against any path until it expires. Require@methodand@pathfrom your agents, or setrequireNonce: true. - A predicate is your SSRF boundary.
trust: (origin) => booleandecides which directories are fetched. Web-standardfetchcannot block private address ranges for you.
| Option | Default | |
|---|---|---|
trust | required | Origins you accept, or (origin) => boolean |
maxAgeMs | 300000 | Oldest created accepted |
clockSkewMs | 5000 | Tolerance for created, expires, and the maximum age |
requireNonce | false | Reject signatures without a nonce; present nonces are always single-use |
cacheTtlMs | 3600000 | Longest a directory is reused; a shorter Cache-Control: max-age wins, never below one minute |
timeoutMs | 3000 | Upper bound for one directory fetch |
fetch | global fetch | For tests, local development, or egress proxies |
Next
Retries
Send the same Idempotency-Key for a logical payment retry, but sign each attempt with fresh Web Bot Auth evidence and a fresh nonce. Requirements run before charge lookup, so the key does not bypass nonce_replayed. See Idempotency.