Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.adcontextprotocol.org/llms.txt

Use this file to discover all available pages before exploring further.

AdCP 3.0 supports HTTP Message Signatures (RFC 9421) for cryptographic request authentication. A buyer signs outbound requests so the seller can verify who sent them and that the payload wasn’t tampered with. A seller signs outbound webhooks so the buyer can verify authenticity. Signing is optional in AdCP 3.0 and becomes mandatory in AdCP 4.0 for all spend-committing operations. Agents that don’t sign yet must still tolerate signature headers (Signature, Signature-Input, Content-Digest) on inbound requests without breaking.
This is the practical implementation guide. For the normative specification β€” covered components, canonicalization rules, the full verifier checklist, replay dedup sizing, and the complete error taxonomy β€” see Security: Signed Requests.
Code examples below use JavaScript/TypeScript, Python, and Go SDK helpers in tabs. All three SDKs implement the same RFC 9421 profile against the same conformance vectors β€” the API surface differs but the wire output is identical. If your language isn’t listed, the conformance vectors and the normative spec are language-agnostic.

When you need this

You are a…You need to…Why
Buyer (calls seller tools)Sign outbound requestsSellers may require proof the request came from you
Buyer (receives webhooks)Verify inbound webhook signaturesConfirm the webhook came from the seller
Seller (receives tool calls)Verify inbound request signaturesConfirm the buyer is who they claim to be
Seller (sends webhooks)Sign outbound webhooksLet buyers verify webhook authenticity
Orchestrator (proxies to sellers)Sign outbound requests + verify inbound webhooksYou’re the buyer from the seller’s perspective

Key concepts

Signature coverage

The AdCP signing profile covers these request components:
  • @method β€” HTTP method
  • @target-uri β€” full canonicalized request URL
  • @authority β€” lowercased host header
  • content-type β€” media type
  • content-digest β€” SHA-256 or SHA-512 hash of the request body (see covers_content_digest capability)
If any covered component changes after signing, verification fails.

Key separation

Every agent needs separate keys per purpose, each with a distinct kid and adcp_use tag:
  • adcp_use: "request-signing" β€” for signing outbound tool calls
  • adcp_use: "webhook-signing" β€” for signing outbound webhooks
Reusing a key across purposes is forbidden by the spec.

Discovery chain

Verifiers find your public key through a three-step chain:
Your domain (e.g., agent.example.com)
  -> /.well-known/brand.json          # brand manifest with agent declarations
     -> agents[].jwks_uri             # pointer to your key store
        -> /.well-known/jwks.json     # JSON Web Key Set with public keys
The @adcp/client SDK provides BrandJsonJwksResolver which handles this chain automatically with caching and refresh.

Step 1: Generate a signing key

CLI

adcp signing generate-key --alg ed25519 --kid my-agent-2026 \
  --private-out ./private.jwk --public-out ./public-jwks.json
This generates an Ed25519 keypair and writes:
  • private.jwk β€” the private key (JWK with d field). Keep this secret.
  • public-jwks.json β€” the public key in JWKS format. Publish this.

Programmatic

import { generateKeyPair, exportJWK } from 'jose';

const { publicKey, privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519' });
const publicJwk = await exportJWK(publicKey);
const privateJwk = await exportJWK(privateKey);

const kid = 'my-agent-2026';
publicJwk.kid = kid;
publicJwk.use = 'sig';
publicJwk.key_ops = ['verify'];
publicJwk.adcp_use = 'request-signing';

Supported algorithms

Algorithmalg valueKey typeNotes
Ed25519ed25519 (RFC 9421) / EdDSA (JWK)OKP / Ed25519Preferred. Fast, small signatures.
ECDSA P-256ecdsa-p256-sha256 (RFC 9421) / ES256 (JWK)EC / P-256Edge-runtime friendly (Cloudflare Workers, Vercel Edge).
The algorithm name differs between the JWK entry ("alg": "EdDSA") and the RFC 9421 Signature-Input parameter (alg="ed25519"). See the algorithm naming table in the spec.

Storing the private key

Pick the strongest option your runtime supports. From most to least secure:
  • Cloud KMS (GCP Cloud KMS, AWS KMS, Azure Key Vault): the private key is generated inside the HSM and never leaves it. Signing is performed by calling the KMS API; you only ever hold the JWK reference, not the key bytes. The TypeScript SDK exposes createKmsSigner for GCP Cloud KMS β€” see @adcp/client/signing/kms. Recommended for any agent handling spend-committing operations.
  • Secret manager (GCP Secret Manager, AWS Secrets Manager, HashiCorp Vault): load at boot, keep in memory for the process lifetime. Easier than KMS but the key material lives in your process β€” leaks via memory dumps, logging, or compromised dependencies.
  • Environment variable: ADCP_SIGNING_PRIVATE_KEY='{"kid":"...","kty":"OKP",...}'. Acceptable for dev and small deployments. Same memory-resident risk as a secret manager.
  • File: development only. Never commit to version control. Use mode 0600 and O_EXCL so an existing file is never overwritten β€” Path.write_bytes inherits the process umask (often 0644, world-readable) and is unsafe for private-key material.
Once you choose KMS, signing latency rises (one round-trip to the HSM per request). Profile under load before committing β€” the TypeScript and Python SDKs cache JWK metadata aggressively and can sustain hundreds of signs/sec against GCP KMS in our internal tests, but your numbers depend on region and concurrency.

Step 2: Publish your public keys

JWKS endpoint

Serve a JSON Web Key Set at a stable HTTPS URL (defaults to /.well-known/jwks.json):
{
  "keys": [
    {
      "kid": "my-agent-2026",
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "<base64url-encoded-public-key>",
      "use": "sig",
      "key_ops": ["verify"],
      "adcp_use": "request-signing"
    }
  ]
}
Only public keys go here β€” no d field. Set Cache-Control: max-age=3600 or similar. If you serve both request-signing and webhook-signing keys, include both in the same JWKS with different kid values and adcp_use tags.

brand.json

Serve at /.well-known/brand.json on your brand domain. The jwks_uri is how verifiers find your keys:
{
  "name": "My Company",
  "domain": "example.com",
  "agents": [
    {
      "url": "https://agent.example.com",
      "jwks_uri": "https://agent.example.com/.well-known/jwks.json",
      "capabilities": ["media-buy"],
      "adcp_use": ["request-signing"]
    }
  ]
}

Step 3: Sign outbound requests (buyer / orchestrator)

Wrapping fetch / HTTP client

createSigningFetch wraps any fetch-compatible function to sign outbound requests automatically:
import { createSigningFetch } from '@adcp/client/signing';

const privateJwk = JSON.parse(process.env.ADCP_SIGNING_PRIVATE_KEY);

const signingFetch = createSigningFetch(fetch, {
  keyid: 'my-agent-2026',
  alg: 'ed25519',
  privateKey: privateJwk,
});

// Use signingFetch anywhere you'd use fetch.
// Signature, Signature-Input, and Content-Digest headers are added automatically.
await signingFetch('https://seller.example.com/mcp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
});

Capability-aware signing

buildAgentSigningFetch checks whether the target seller supports signed-requests and only signs when supported. This is the recommended approach for production:
import { buildAgentSigningFetch, CapabilityCache } from '@adcp/client/signing/client';

const capabilityCache = new CapabilityCache();

const signingFetch = buildAgentSigningFetch({
  upstream: fetch,
  signing: {
    kid: 'my-agent-2026',
    alg: 'ed25519',
    private_key: privateJwk,
    agent_url: 'https://agent.example.com',
    sign_supported: true,
  },
  getCapability: () => capabilityCache.get('https://seller.example.com'),
});
This avoids sending signatures to agents that don’t expect them and caches capability lookups.

Step 4: Verify inbound signatures (seller)

Framework middleware

For raw Express routes, mount createExpressVerifier after a raw-body middleware. Use mcpToolNameResolver for the resolveOperation callback β€” it parses the JSON-RPC envelope and returns the MCP tool name:
import {
  createExpressVerifier,
  StaticJwksResolver,
  InMemoryReplayStore,
  InMemoryRevocationStore,
} from '@adcp/client/signing';
import { mcpToolNameResolver } from '@adcp/client/server';

app.post(
  '/mcp',
  rawBodyMiddleware(), // req.rawBody must hold the byte-exact body
  createExpressVerifier({
    capability: {
      supported: true,
      covers_content_digest: 'required',
      required_for: ['create_media_buy', 'update_media_buy'],
    },
    jwks: new StaticJwksResolver(buyerPublicKeys),
    replayStore: new InMemoryReplayStore(),
    revocationStore: new InMemoryRevocationStore(),
    resolveOperation: mcpToolNameResolver,
  }),
  handler
);
// On verify: req.verifiedSigner = { keyid, agent_url?, verified_at }.
// On reject: 401 with WWW-Authenticate: Signature error="<code>".

Composing signature + bearer auth with requireAuthenticatedOrSigned

requireAuthenticatedOrSigned bundles the full composition: presence-gated routing (signature auth when headers present, fallback otherwise) plus requiredFor enforcement β€” unauthenticated requests for signing-required operations get 401 request_signature_required even when no credentials at all are supplied.
import {
  serve,
  verifyApiKey,
  verifySignatureAsAuthenticator,
  requireAuthenticatedOrSigned,
  mcpToolNameResolver,
  MUTATING_TASKS,
} from '@adcp/client/server';
import { BrandJsonJwksResolver, InMemoryReplayStore, InMemoryRevocationStore } from '@adcp/client/signing/server';

serve(createAgent, {
  authenticate: requireAuthenticatedOrSigned({
    signature: verifySignatureAsAuthenticator({
      capability: { supported: true, required_for: ['create_media_buy'], covers_content_digest: 'either' },
      jwks: new BrandJsonJwksResolver(),
      replayStore: new InMemoryReplayStore(),
      revocationStore: new InMemoryRevocationStore(),
      resolveOperation: mcpToolNameResolver,
    }),
    fallback: verifyApiKey({ keys: { 'sk_live_abc': { principal: 'acct_42' } } }),
    requiredFor: [...MUTATING_TASKS],
    resolveOperation: mcpToolNameResolver,
  }),
});
MUTATING_TASKS is the full list of spend-committing and state-changing operations exported from @adcp/client/server β€” use it rather than maintaining your own list.

JWKS resolver options

ResolverUse case
StaticJwksResolverFixed set of known buyer keys. Good for dev/testing.
HttpsJwksResolverFetches JWKS from a URL with caching and refresh.
BrandJsonJwksResolverFull discovery chain: brand.json β†’ jwks_uri β†’ JWKS. Recommended for production.

Step 5: Verify inbound webhooks (buyer / orchestrator)

When sellers send webhooks, verify the signature to confirm authenticity. The webhook profile uses the same RFC 9421 mechanics as request signing but with tag="adcp/webhook-signing/v1" and Content-Digest always covered (no opt-out).
import {
  verifyWebhookSignature,
  BrandJsonJwksResolver,
  InMemoryReplayStore,
} from '@adcp/client/signing/server';

const jwks = new BrandJsonJwksResolver();
const replayStore = new InMemoryReplayStore();

app.post('/webhook', async (req, res) => {
  try {
    await verifyWebhookSignature(req, { jwks, replayStore });
  } catch {
    return res.status(401).json({ error: 'invalid webhook signature' });
  }

  // Process the verified webhook...
});

Step 6: Sign outbound webhooks (seller)

Pass a signerKey to createAdcpServer and the framework signs every outbound webhook automatically:
serve(() => createAdcpServer({
  name: 'My Seller',
  version: '1.0.0',
  webhooks: {
    signerKey: {
      keyid: 'my-seller-webhook-2026',
      alg: 'ed25519',
      privateKey: webhookPrivateJwk,
    },
  },
  mediaBuy: { /* ... */ },
}));
Publish a separate JWK with "adcp_use": "webhook-signing" in your JWKS alongside your request-signing key. Never reuse the same key for both purposes β€” receivers enforce purpose at the JWK adcp_use level, not the RFC 9421 tag.

Step 7: Declare the capability

If your seller verifies inbound signatures, declare signed_requests (alias request_signing in the on-wire schema) in your get_adcp_capabilities response so buyers know to sign:
createAdcpServer({
  capabilities: {
    overrides: {
      signed_requests: {
        supported: true,
        required_for: ['create_media_buy', 'update_media_buy'],
        supported_for: ['sync_creatives', 'sync_audiences'],
        covers_content_digest: 'either',
      },
    },
  },
  mediaBuy: { /* ... */ },
});
Buyers call get_adcp_capabilities and read request_signing.required_for and supported_for to know which operations you expect them to sign.

Key rotation

The JWKS endpoint supports multiple keys simultaneously for zero-downtime rotation:
  1. Generate a new keypair with a new kid
  2. Add the new public key to JWKS (both old and new are published)
  3. Update signing configuration to use the new private key
  4. After 24–48 hours, remove the old public key from JWKS
For emergency rotation (key compromise), add the old kid to revoked_kids in your revocation list and rotate to a new key immediately. See Revocation for the revocation list format.

Testing

Conformance vectors

The spec ships 39 test vectors at compliance/cache/3.0.0/test-vectors/request-signing/ (source at static/compliance/source/test-vectors/request-signing/):
  • 12 positive vectors: valid signed requests your verifier must accept (non-4xx)
  • 27 negative vectors: invalid requests your verifier must reject with 401 and the correct error code
# Debug a single vector
adcp signing verify-vector \
  --vector compliance/cache/3.0.0/test-vectors/request-signing/positive/001-basic-post.json

Grade your verifier

adcp grade request-signing https://agent.example.com/mcp --auth-token $TOKEN

Error codes

When verification fails, return 401 with WWW-Authenticate: Signature error="<code>":
CodeMeaning
missing_signatureSignature headers not present when required
invalid_signatureSignature doesn’t verify against the public key
expired_signatureSignature timestamp too old
replayed_nonceNonce was already used
revoked_keyKey has been revoked
unknown_keyKey ID not found in JWKS
unsupported_algorithmAlgorithm not in allowlist
For the full error code taxonomy, see Transport error taxonomy.