@mconroy-cf/webmcp-auth-workers
v0.1.0-experimental.0
Published
EXPERIMENTAL — Web Bot Auth verification, per-agent rules, rate limiting, and BLOCK_ALL kill-switch for WebMCP tool surfaces on Cloudflare Workers. Published to gather partner feedback; expect breaking changes; not for production traffic.
Maintainers
Readme
⚠️ EXPERIMENTAL — partner-feedback release.
This package is published to a personal scope (
@mconroy-cf/) so external partners can install and try it without waiting on the formal Cloudflare release process. Expect breaking changes; not for production traffic. Send feedback to [email protected] — we'd rather know what's missing than have you wait for a polished v1. Once an official Cloudflare-scoped release ships, this package will deprecate and point at it.
@mconroy-cf/webmcp-auth-workers
Web Bot Auth verification, per-agent rules, rate limiting, content-digest enforcement, KYA hooks, and a
BLOCK_ALLkill-switch for WebMCP tool surfaces fronted by Cloudflare Workers.
import {
Authenticator,
httpDirectory,
} from "@mconroy-cf/webmcp-auth-workers";
const auth = new Authenticator({
// Tool-level ACLs are configured below — the Authenticator will
// automatically force `contentDigest: "required"` on the verifier.
defaultPolicy: { allowTools: ["search", "get_product"] },
agents: [
{ agentId: "openai-chatgpt", allowTools: "*" },
{ agentId: "noisy-startup", denyTools: ["checkout"] },
],
directory: httpDirectory({
url: "https://allow-list.merchant.example.com/agents.json",
}),
rateLimiter: env.MCP_RATE,
blockAll: env.BLOCK_AGENTS === "true",
verify: {
nonceStore: env.NONCE_STORE,
// No `jwksUrls` configured → trust the directory each request's
// Signature-Agent header advertises (in-band discovery).
signatureAgentDiscovery: "trust",
},
});
export default {
async fetch(request: Request, env: Env) {
const decision = await auth.authorize(request);
if (!decision.ok) return decision.toResponse();
return fetch(env.MCP_ORIGIN_URL, {
method: request.method,
headers: { ...Object.fromEntries(request.headers), ...Object.fromEntries(decision.forwardHeaders()) },
body: decision.rawBody || null,
});
},
};What this does
Implements the merchant-side enforcement points for agent traffic on WebMCP tool surfaces:
- ✅ Verifies the Web Bot Auth HTTP message signature (built on RFC 9421) on every request.
- ✅ Parses multi-label
Signature/Signature-InputStructured Field Dictionaries. - ✅ Resolves
keyidas the RFC 7638 SHA-256 JWK thumbprint, with akid-string fallback for legacy directories. - ✅ Honours the
Signature-Agentheader for in-band directory discovery, per draft-meunier-http-message-signatures-directory. - ✅ Verifies Ed25519, RSA-PSS-SHA512 (spec default), and RSA-PSS-SHA256 (compat).
- ✅ Enforces freshness (
expires - created ≤ 8 min,created ≤ now ≤ expires). - ✅ Rejects replayed nonces via KV-backed cache.
- ✅ Optional RFC 9530
Content-Digestverification — automatically required when tool-level ACLs are configured, because tool ACLs are unsound without body integrity. - ✅ Resolves the verified
keyidto a stable agent identity via a pluggable directory (staticDirectory,httpDirectory, or your own). - ✅ Per-agent allow/deny tool ACLs.
- ✅ Per-agent rate limiting via the Workers Rate Limiting API (keyed on the verified identity, not IP).
- ✅ Optional KYA token verification — bring your own verifier.
- ✅
BLOCK_ALLkill-switch.
Install
npm install @mconroy-cf/webmcp-auth-workersThree entry points, in increasing order of opinion
1. verifyWebBotAuth(request, options)
Pure signature verifier. Returns { ok: true, rawBody, info } or a
failure with toResponse(). Use this if you want to write your own
policy layer.
import { verifyWebBotAuth } from "@mconroy-cf/webmcp-auth-workers";
const result = await verifyWebBotAuth(request, {
nonceStore: env.NONCE_STORE,
signatureAgentDiscovery: "trust",
});
if (!result.ok) return result.toResponse();
console.log("signed by", result.info.keyid, "label", result.info.label);2. Authenticator
Full policy gate: signature → directory → ACL → rate limit → KYA →
forward. Sound-by-default: with tool ACLs configured the
Authenticator upgrades the verifier to contentDigest: "required"
and refuses to start if the merchant has explicitly set it to
"optional".
3. httpDirectory({ url }) / staticDirectory(map) / composeDirectories(...)
Directory implementations. There is no library-wide default URL —
the spec's primary discovery mechanism is the per-request
Signature-Agent header, which the verifier honours when
signatureAgentDiscovery: "trust" (default for verifyWebBotAuth,
inherited by Authenticator).
Options matrix
Authenticator constructor
| Option | Type | Default | Notes |
|---|---|---|---|
| verify | VerifyWebBotAuthOptions | {} | Forwarded to the verifier. |
| directory | AgentDirectory | none | Required when agents is non-empty. Without one, the keyid is surfaced as the agent id. |
| blockAll | boolean | false | Kill-switch. When true, every request returns 403 blocked_by_policy. |
| defaultPolicy | ToolPolicy | undefined | Applied to any agent without a per-agent override. Without any policy at all, the Authenticator admits any verified agent. |
| agents | AgentPolicy[] | [] | Per-agent overrides. Match by agentId (resolved from the directory). |
| extractToolName | (rawBody) => string | JSON-RPC tools/call.params.name | Override for non-JSON-RPC surfaces. JSON-RPC batches return undefined. |
| rateLimiter | RateLimiter | none | Workers Rate Limiting API binding. Limits keyed on agentId. |
| kya | KyaPolicy | { mode: "off" } | Optional KYA token verification. |
verifyWebBotAuth options
| Option | Type | Default | Notes |
|---|---|---|---|
| jwksUrls | string[] | [] | Statically-trusted directory endpoints (highest trust). |
| signatureAgentDiscovery | "trust" \| "ignore" | "trust" | Honour the Signature-Agent header for in-band directory discovery. |
| selfJwks | JwkSet | undefined | Inline keys for same-origin lookups (Workers can't fetch their own origin). |
| nonceStore | NonceStore (KV-shaped) | warns if omitted | Replay protection. Required in production. |
| maxWindowSeconds | number | 480 (8 min) | Freshness cap. |
| contentDigest | "optional" \| "required" | "optional" | When "required", request MUST carry content-digest AND it MUST be in covered fields. |
| now | () => number | Date.now()/1000 | Override only in tests. |
| testPublicKey | { keyid, base64 } | undefined | Dev-only Ed25519 fallback. Remove from production. |
Failure reasons
Stable, machine-readable reason codes returned on result.reason. Safe
to branch on.
| Reason | Status | Layer |
|---|---|---|
| missing_signature_headers | 401 | signature |
| signature_input_malformed | 400 | signature |
| missing_required_param | 400 | signature |
| wrong_tag | 401 | signature |
| unsupported_alg | 400 | signature |
| timestamp_not_integer | 400 | signature |
| window_too_large | 401 | signature |
| created_in_future | 401 | signature |
| signature_expired | 401 | signature |
| nonce_replay | 401 | signature |
| unknown_keyid | 401 | signature |
| unsupported_covered_field | 400 | signature |
| missing_required_covered_field | 400 | signature |
| signature_malformed | 400 | signature |
| signature_invalid | 401 | signature |
| content_digest_required | 400/401 | signature |
| content_digest_invalid | 401 | signature |
| content_digest_mismatch | 401 | signature |
| blocked_by_policy | 403 | policy (BLOCK_ALL) |
| agent_not_in_directory | 403 | policy |
| agent_denied | 403 | policy |
| tool_denied | 403 | policy (allow/deny ACL) |
| rate_limited | 429 | policy |
| kya_required | 401 | KYA |
| kya_invalid | 401 | KYA (or 500 if mis-configured) |
Threat model
The library answers four threats explicit in the partner conversations that motivated this work:
- "A real customer using Gemini's side panel" vs. "an automated
agent calling our tools remotely from a script." Web Bot Auth +
directory resolution means only requests carrying a signature
minted by an agent operator a directory we trust knows about will
pass. Unsigned requests fail with
missing_signature_headers. - Replay across hosts or paths.
@authority(or@target-uri/@path) is required under signature. A captured signature can't be replayed against a different merchant or a privileged path. - Replay against the same host. Each accepted nonce is recorded
in KV with an 8-minute TTL. Subsequent requests with the same
nonce inside that window fail with
nonce_replay. - Body tampering on tool calls. Web Bot Auth doesn't sign the
body by default. The
AuthenticatorforcescontentDigest: "required"whenever a tool-level ACL is in play, so an attacker can't swap the JSON-RPC body to call a different tool than the one the agent signed. - "We changed our mind, we want to turn this off right now." The
blockAllflag is a single boolean a merchant can flip via env var to return 403 to every signed agent caller without redeploying any tool logic.
Out of scope (for this version)
- Concurrent-millisecond replays against a single nonce — KV is not transactional. Substitute a Durable Object NonceStore for that guarantee. The verifier is unchanged.
- Cross-isolate cache coherence for the JWKS / directory caches. Both are 5-minute per-isolate; a rotated key takes that long to propagate everywhere.
- Directory authority signatures (spec §5.2) — directory responses themselves can be signed. Today we trust the transport (HTTPS) for the directory fetch.
data:URISignature-Agentvalues (inline directory bytes). Loading JWK material straight out of the request deserves its own audit; deferring to a follow-up.- Body integrity without
content-digest. If the agent operator doesn't includecontent-digestin the covered fields, the body isn't bound to the signature. Verifier surfaces this asinfo.contentDigestVerified: false. Decide whether to admit such requests by settingcontentDigest: "required"on the verifier (or let theAuthenticatordo it for you when ACLs are configured).
License
MIT.
