@mconroy-cf/tapkit-workers
v0.5.0-experimental.1
Published
EXPERIMENTAL — drop-in TAP (Trusted Agent Protocol) verification middleware for Cloudflare Workers. Verifies agent recognition signatures on incoming requests per the Visa TAP v1 spec. Published to gather partner feedback; expect breaking changes; not for
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/tapkit-workers
Drop-in TAP (Trusted Agent Protocol) verification middleware for Cloudflare Workers.
Verify Trusted Agent Protocol agent-recognition signatures on incoming Worker requests with one import and ~10 lines of code.
import { verifyTap } from "@mconroy-cf/tapkit-workers";
export default {
async fetch(request: Request, env: Env) {
const result = await verifyTap(request, {
tag: "agent-payer-auth",
nonceStore: env.NONCE_STORE,
});
if (!result.ok) return result.toResponse();
// Signature verified. result.rawBody is the request body
// (the original request stream has been consumed).
const body = JSON.parse(result.rawBody);
// …your checkout logic
}
};Install
npm install @mconroy-cf/tapkit-workersWhat this does
Implements merchant-side verification of the TAP v1 Agent Recognition Signature:
- ✅ Parses
Signature-Input/Signatureheaders per RFC 9421 - ✅ Enforces required params (
created,expires,keyid,alg,nonce,tag) - ✅ Checks the tag matches what your route expects (
agent-browser-authoragent-payer-auth) - ✅ Enforces 8-minute freshness window (spec max)
- ✅ Rejects replayed nonces via KV-backed cache
- ✅ Discovers public keys via JWKS (defaults to Visa's production endpoint)
- ✅ Verifies Ed25519 and RSA-PSS-SHA256 signatures
- ✅ Rebuilds the signature base byte-identically to the signer
Layer 2 (Consumer Recognition Object) is supported as of v0.4:
- ✅ Pass
options.cro: "optional"or"required"— the verifier reads theagenticConsumerJSON field out of the request body, verifies its signature with the same key as the message signature, checks nonce linkage, and surfaces the result oninfo.cro.
Layer 3 (Agentic Payment Container) is supported as of v0.5:
- ✅ Pass
options.apc: "optional"or"required"— the verifier reads theagenticPaymentContainerJSON field out of the request body, verifies its signature, checks nonce linkage, and surfaces the container (credential hash, card metadata, encrypted payload, or browsing IOU) oninfo.apc.
Both layers can be enabled together on the same request; they share the message-signature nonce and both link back to it.
Quick start
1. Bind a KV namespace for nonce replay protection
In wrangler.jsonc:
{
"kv_namespaces": [
{ "binding": "NONCE_STORE", "id": "<your-kv-id>" }
]
}2. Verify incoming requests
import { verifyTap, TAP_TAGS } from "@mconroy-cf/tapkit-workers";
interface Env {
NONCE_STORE: KVNamespace;
}
export default {
async fetch(request: Request, env: Env) {
if (new URL(request.url).pathname === "/checkout") {
const result = await verifyTap(request, {
tag: TAP_TAGS.PAYER, // "agent-payer-auth"
nonceStore: env.NONCE_STORE,
});
if (!result.ok) return result.toResponse();
const { productSlug, quantity } = JSON.parse(result.rawBody);
return Response.json({ status: "approved", productSlug, quantity });
}
return new Response("Not found", { status: 404 });
}
};That's it. With defaults, the middleware trusts Visa's production JWKS (https://mcp.visa.com/.well-known/jwks) as the source of agent public keys.
API
verifyTap(request, options): Promise<VerifyResult>
Verifies the TAP signature on an incoming Request. Returns a discriminated union — either { ok: true, rawBody, info } or { ok: false, status, reason, toResponse() }.
⚠️ Body consumption. verifyTap() reads the request body to pass it back to you in result.rawBody. The original request.body stream is consumed and can't be re-read. Always use rawBody from the verification result for POST/PUT/PATCH.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
| tag | "agent-browser-auth" \| "agent-payer-auth" \| "any" | required | Which TAP tag to accept. Use "any" to accept either (rare). |
| cro | "optional" \| "required" \| undefined | undefined | Consumer Recognition Object (TAP layer 2) policy. See CRO below. |
| apc | "optional" \| "required" \| undefined | undefined | Agentic Payment Container (TAP layer 3) policy. See APC below. |
| nonceStore | KVNamespace | (warns if omitted) | KV for replay protection. Required in production. |
| jwksUrls | string[] | ["https://mcp.visa.com/.well-known/jwks"] | JWKS endpoints to trust, in order. First matching kid wins. |
| selfJwks | JwkSet | (undefined) | Inline keys for same-origin JWKS. See Self-hosted JWKS below. |
| testPublicKey | { keyid, base64 } | (undefined) | Dev-only fallback. Raw 32-byte Ed25519 public key, base64. |
| maxWindowSeconds | number | 480 (8 min) | TAP v1 spec max. Override only for tests. |
| now | () => number | Date.now()/1000 floor | Clock source. Override only for tests. |
Result shapes
// Success
{
ok: true,
rawBody: string, // the request body (use this, not request.text())
info: {
tag: "agent-browser-auth" | "agent-payer-auth",
keyid: string,
alg: "ed25519" | "rsa-pss-sha256",
keySource: string, // where we found the key ("https://…/jwks" or "fallback:testPublicKey")
created: number,
expires: number,
nonce: string,
coveredFields: CoveredField[], // { name, params, raw }[]
}
}
// Failure
{
ok: false,
status: number, // 400 (malformed) or 401 (auth failure)
reason: VerifyFailureReason,
detail?: Record<string, unknown>,
toResponse(): Response // 4xx JSON { verified: false, reason }
}VerifyFailureReason values
Stable machine-readable reason codes. Safe to branch on.
| Reason | Status |
|---|---|
| missing_signature_headers | 401 |
| signature_input_malformed | 400 |
| missing_required_param | 400 |
| wrong_tag | 401 |
| unsupported_alg | 400 |
| timestamp_not_integer | 400 |
| window_too_large | 401 |
| created_in_future | 401 |
| signature_expired | 401 |
| nonce_replay | 401 |
| unknown_keyid | 401 |
| unsupported_covered_field | 400 |
| signature_malformed | 400 |
| signature_invalid | 401 |
| cro_missing | 401 |
| cro_malformed | 401 |
| cro_missing_required_field | 401 |
| cro_nonce_mismatch | 401 |
| cro_unsupported_alg | 401 |
| cro_unknown_kid | 401 |
| cro_signature_invalid | 401 |
| apc_missing | 401 |
| apc_malformed | 401 |
| apc_missing_required_field | 401 |
| apc_nonce_mismatch | 401 |
| apc_unsupported_alg | 401 |
| apc_unknown_kid | 401 |
| apc_signature_invalid | 401 |
CRO and APC reasons are only returned when the corresponding policy is
"required". Under "optional" they appear on info.cro.status /
info.apc.status = "invalid" instead.
Security notes
Nonce replay protection
Each accepted nonce is written to the configured nonceStore KV namespace with an 8-minute TTL. Subsequent requests with the same nonce within that window are rejected.
What this catches: sequential replays (the common attack). First request succeeds, second arrives seconds to minutes later and hits the stored nonce.
What this doesn't catch: concurrent replays arriving within the same millisecond — KV is not transactional, so two parallel get(nonce) calls can both return null before either put() completes. For strong protection against concurrent replays, replace KV with a Durable Object (one DO per merchant, holding the nonce set). The rest of the verifier is unchanged.
If you omit nonceStore the middleware still verifies the signature but logs a one-off warning to stderr. Fine for local dev, unsafe in production.
JWKS discovery
On each request with a new keyid, the middleware fetches each configured JWKS URL in order and returns the first key matching kid. Results are cached per-isolate for 5 minutes.
The default trusts only https://mcp.visa.com/.well-known/jwks — Visa's production JWKS, the canonical TAP issuer today. Add more issuers as the agent ecosystem grows:
await verifyTap(request, {
tag: "agent-payer-auth",
nonceStore: env.NONCE_STORE,
jwksUrls: [
"https://mcp.visa.com/.well-known/jwks",
"https://issuer2.example.com/.well-known/jwks",
],
});Self-hosted JWKS
Agent issuers publish JWKS, not merchants — so most merchants don't need this. If you do self-host (typically for testing), pass the JWK Set inline via selfJwks. Workers disallow a Worker from fetching its own origin over the public internet, so the middleware short-circuits same-origin lookups to the inline set:
await verifyTap(request, {
tag: TAP_TAGS.BROWSER,
nonceStore: env.NONCE_STORE,
jwksUrls: [
`${new URL(request.url).origin}/.well-known/jwks`,
"https://mcp.visa.com/.well-known/jwks",
],
selfJwks: { keys: [/* your JWK(s) */] },
});CRO (Consumer Recognition Object — TAP layer 2)
The CRO is a JSON object placed in the request body that carries verified consumer identity (ID token, contextual data like country + zip, device fingerprint) signed by the same agent key as the message signature.
Enable it via options.cro:
await verifyTap(request, {
tag: "agent-payer-auth",
nonceStore: env.NONCE_STORE,
cro: "optional", // or "required"
});
// On success: result.info.cro is { status: "verified", consumer }
// { status: "absent" }
// { status: "invalid", reason }"optional" (recommended for gradual adoption): the verifier reads
the CRO if present, verifies its signature, and surfaces the outcome
on info.cro. If the CRO is missing, malformed, or fails to verify,
the overall verifyTap result still succeeds — merchants can see the
situation on info.cro.status and decide whether to use the data.
"required": the verifier rejects the whole request with a
cro_* reason code if the CRO is missing, malformed, or fails to
verify. Use this once you've flipped your merchant over to require
layer-2 consumer recognition on payment endpoints.
Canonical signature base. The TAP spec leaves the exact byte-for-
byte encoding of the signature base underspecified. This package uses
JSON.stringify of the agenticConsumer object with signature
removed, field order preserved from insertion, no whitespace.
The TAPKit CLI signer (signCro()) uses the same rule. Documented in
src/cro.ts.
APC (Agentic Payment Container — TAP layer 3)
The APC is a JSON object in the request body carrying payment data signed by the same agent key as the message signature. Unlike the CRO (which is about consumer identity), the APC is about payment mechanics — a hash of PAN+exp+CVV for guest-checkout key entry, card-on-file metadata for reconciliation, an encrypted payload for API payment rails, or a browsing IOU for 402-response paid access.
Enable via options.apc:
await verifyTap(request, {
tag: "agent-payer-auth",
nonceStore: env.NONCE_STORE,
apc: "optional", // or "required"
});
// On success: result.info.apc is { status: "verified", container }
// { status: "absent" }
// { status: "invalid", reason }The verifier validates the container shape (signature + nonce linkage + required fields). It does not validate the semantics of the payload variants — those depend on the payment rail:
paymentCredentialsHash: the merchant re-hashes the key-entered PAN and compares to the hash here.cardMetadata: the merchant useslastFour/paymentAccountReferencefor display and reconciliation.payload: the merchant decrypts with their onboarded RSA private key.browsingIOU: the merchant cross-checks against the 402 it issued.
v0.5 ships verification for all of them at the container level; the payload semantics are the merchant's integration with their payment processor.
Query-param signed fields (RFC 9421 query-param derived component)
TAP v1 layer 1 covers "@authority" and "@path" by default, but TAP's
"query-param flavour" (used by Visa's public reference) signs specific
URL query parameters individually via the RFC 9421 query-param
derived component:
Signature-Input: sig2=("@authority" "@path" "query-param";name="agent-id")
; created=…; expires=…; keyId="…"; alg="ed25519"
; nonce="…"; tag="agent-payer-auth"Nothing extra to configure — the verifier handles "query-param" entries
automatically whenever they appear in Signature-Input. For each entry it
pulls the named parameter's value off the request URL and includes it in
the signature base at the expected line:
"@authority": m.example.com
"@path": /checkout?agent-id=chatgpt
"query-param";name="agent-id": chatgpt
"@signature-params": (…)Tampering with a signed query-param value fails verification with
signature_invalid. Removing a signed query-param from the URL fails
with unsupported_covered_field. Any query-string tampering also
changes @path (which TAPKit keeps as pathname + search to match
Visa's reference) so tamper-evidence is doubly enforced.
The TAPKit CLI emits this flavour via new TapSigner({ signedQueryParams:
["agent-id", "consumer-hash"] }). A merchant using
@mconroy-cf/tapkit-workers accepts it with zero code changes.
Dev fallback: testPublicKey
For local testing without a JWKS endpoint, wire a single known Ed25519 public key:
testPublicKey: {
keyid: "my-test-key",
base64: "Yqf7iYq+i5MV3HPYEDyWK3DrkpEsB958kQ2lLrxws6A=", // raw 32 bytes, base64
}Remove from production deploys.
Interop
This middleware's verifier is the same code that powers the TAPKit reference merchant. That reference merchant is covered by the @mconroy-cf/tapkit-cli check conformance suite (13 scenarios across the TAP v1 verification rules, including CRO/APC) and has been tested end-to-end against TAPKit's own signing CLI.
Verification against Visa's live tap-agent (RSA-PSS path) is planned and infrastructure is in place. See the TAPKit roadmap.
Testing against your deployment
Once your Worker is live with verifyTap() wired in, run the TAPKit conformance check from the companion CLI:
npx @mconroy-cf/tapkit-cli check https://your-merchant.example.comThat exercises 13 scenarios (valid / replay / expired / window-too-large / wrong-tag × 2 / missing-signature / unknown-keyid / malformed / CRO valid / CRO nonce-mismatch / APC valid / APC nonce-mismatch) and reports pass/fail per scenario. Run npx @mconroy-cf/tapkit-cli list-scenarios to see the current list.
The CLI is published alongside this package on the same @mconroy-cf/ partner-feedback scope. Once the official @cloudflare/-scoped release ships, both packages will deprecate and point at the canonical names.
Licence
MIT.
