npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
Tollstile

Admit only verified agents

Require Web Bot Auth HTTP message signatures from trusted agents with verifiedAgent() from @tollstile/web-bot-auth, alongside payment.

Public Beta ยท early access

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-server

1. Require a signature

server.ts
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

keys.ts
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.

agent.ts
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.ts
node 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 signed
HTTP/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

ReasonStatusMeaning
signature_missing, signature_malformed, tag_missing403No usable Web Bot Auth signature
signature_expired, signature_too_old, signature_not_yet_valid403Outside created / expires, maxAgeMs, or clockSkewMs
agent_untrusted403The Signature-Agent origin is not in trust; nothing was fetched
key_not_found, algorithm_mismatch, signature_invalid403Key selection or cryptographic verification failed
nonce_missing, nonce_replayed403Replay protection
http_request_required403MCP tool calls are never attributed to a signed HTTP request
directory_invalid403The directory answered, but with a redirect, another non-200 status, or malformed or oversized content
directory_unavailable503The 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. @authority and @path come from request.url. Behind a proxy, the adapter must see the URL the agent signed (for example, Express trust proxy).
  • Ask for more than @authority. A signature over @authority alone can be replayed against any path until it expires. Require @method and @path from your agents, or set requireNonce: true.
  • A predicate is your SSRF boundary. trust: (origin) => boolean decides which directories are fetched. Web-standard fetch cannot block private address ranges for you.
OptionDefault
trustrequiredOrigins you accept, or (origin) => boolean
maxAgeMs300000Oldest created accepted
clockSkewMs5000Tolerance for created, expires, and the maximum age
requireNoncefalseReject signatures without a nonce; present nonces are always single-use
cacheTtlMs3600000Longest a directory is reused; a shorter Cache-Control: max-age wins, never below one minute
timeoutMs3000Upper bound for one directory fetch
fetchglobal fetchFor 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.

On this page