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 requests | Sellers may require proof the request came from you |
| Buyer (receives webhooks) | Verify inbound webhook signatures | Confirm the webhook came from the seller |
| Seller (receives tool calls) | Verify inbound request signatures | Confirm the buyer is who they claim to be |
| Seller (sends webhooks) | Sign outbound webhooks | Let buyers verify webhook authenticity |
| Orchestrator (proxies to sellers) | Sign outbound requests + verify inbound webhooks | Youβ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
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
JavaScript/TypeScript
Python
Go
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';
from adcp.signing import generate_signing_keypair
# CLI equivalence: adcp-keygen --alg ed25519 --purpose request-signing --kid my-agent-2026
pem_bytes, public_jwk = generate_signing_keypair(
alg="ed25519",
purpose="request-signing",
kid="my-agent-2026",
)
# pem_bytes β write to disk with mode 0600 and O_EXCL, or pass to a secret manager.
# public_jwk β publish in your JWKS endpoint. Already includes kid, use, key_ops, and adcp_use.
import "github.com/adcontextprotocol/adcp-go/adcp/signing"
res, err := signing.GenerateKeyForProfile(
signing.AlgEd25519,
"my-agent-2026",
signing.ProfileRequestSigning,
)
if err != nil { /* handle */ }
// res.PrivateKeyPEM β write to disk with mode 0600, or pass to a secret manager.
// res.PublicJWK β serialize and publish in your JWKS endpoint. AdCP-required
// fields (kid, kty, crv, alg, use, key_ops, adcp_use) are set.
Use signing.ProfileWebhookSigning for webhook keys β never reuse a request-signing key for webhook signing (per adcp_use purpose separation).
Supported algorithms
| Algorithm | alg value | Key type | Notes |
|---|
| Ed25519 | ed25519 (RFC 9421) / EdDSA (JWK) | OKP / Ed25519 | Preferred. Fast, small signatures. |
| ECDSA P-256 | ecdsa-p256-sha256 (RFC 9421) / ES256 (JWK) | EC / P-256 | Edge-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
JavaScript/TypeScript
Python
Go
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),
});
The Python SDK auto-signs every outbound request when signing is configured on ADCPClient:from adcp import ADCPClient
from adcp.signing import SigningConfig, load_private_key_pem
private_key = load_private_key_pem(open("private-key.pem", "rb").read())
client = ADCPClient(
base_url="https://seller.example.com/mcp",
signing=SigningConfig(
key_id="my-agent-2026",
alg="ed25519",
private_key=private_key,
cover_content_digest=True,
),
)
# Every request the client sends carries Signature, Signature-Input, and
# Content-Digest headers.
For lower-level control (e.g., signing an arbitrary httpx.Request outside the client), call sign_request directly:from adcp.signing import sign_request
signed = sign_request(
method="POST",
url="https://seller.example.com/mcp",
headers={"Content-Type": "application/json"},
body=request_body_bytes,
private_key=private_key,
key_id="my-agent-2026",
alg="ed25519",
cover_content_digest=True,
)
# signed.as_dict() returns the headers to attach to the outgoing request.
The Go SDK exposes a Signer you wrap your http.Client transport with:import (
"net/http"
"github.com/adcontextprotocol/adcp-go/adcp/signing"
)
priv, _, _ := signing.LoadPrivateKey(pemBytes)
signer, _ := signing.NewSigner(signing.SignerOptions{
KeyID: "my-agent-2026",
Algorithm: signing.AlgEd25519,
PrivateKey: priv,
})
client := &http.Client{
Transport: signer.RoundTripper(http.DefaultTransport, true /* cover content-digest */),
}
req, _ := http.NewRequest("POST", "https://seller.example.com/mcp", body)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
// Signature, Signature-Input, and Content-Digest are added by the transport.
For one-off signing without the transport, call signer.SignRequest(req, signing.SignOptions{CoverContentDigest: true}) directly on the request.
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
JavaScript/TypeScript
Python
Go
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>".
The Python SDK ships framework wrappers for Flask and Starlette/FastAPI. Both call into the same verify_request_signature and raise SignatureVerificationError on rejection β map that to a 401 with unauthorized_response_headers:from fastapi import FastAPI, Request, HTTPException
from adcp.signing import (
VerifyOptions,
VerifierCapability,
CachingJwksResolver,
InMemoryReplayStore,
StaticRevocationChecker,
)
from adcp.signing.middleware import (
verify_starlette_request,
unauthorized_response_headers,
)
from adcp.signing.errors import SignatureVerificationError
app = FastAPI()
verify_options = VerifyOptions(
capability=VerifierCapability(
supported=True,
covers_content_digest="required",
required_for={"create_media_buy", "update_media_buy"},
),
jwks_resolver=CachingJwksResolver(),
replay_store=InMemoryReplayStore(),
revocation_checker=StaticRevocationChecker(set()),
)
@app.post("/mcp")
async def mcp(request: Request):
try:
signer = await verify_starlette_request(request, options=verify_options)
except SignatureVerificationError as exc:
raise HTTPException(
status_code=401,
detail=exc.code,
headers=unauthorized_response_headers(exc),
)
# signer.key_id, signer.agent_url, signer.verified_at available for audit.
body = await request.body()
return await handle_mcp(body, signer)
For Flask: swap verify_starlette_request for verify_flask_request (sync). For non-Starlette ASGI frameworks, call verify_request_signature directly. Mount signing.Middleware in your http.Handler chain. The middleware verifies inbound signatures, populates a VerifiedSigner in the request context on success, and writes a 401 with WWW-Authenticate: Signature error="<code>" on failure:import (
"net/http"
"github.com/adcontextprotocol/adcp-go/adcp/signing"
)
resolver := signing.NewCachingJWKSResolver()
replay := signing.NewMemoryReplayStore(0 /* default cap */)
revocation := signing.NewStaticRevocationSource(nil)
mw := signing.Middleware(signing.MiddlewareOptions{
Resolver: resolver,
Replay: replay,
Revocation: revocation,
OperationResolver: signing.DefaultOperationResolver, // /adcp/<op>
ContentDigestPolicy: signing.DigestRequired,
RequiredFor: []string{"create_media_buy", "update_media_buy"},
})
http.Handle("/mcp", mw(handler))
// Inside handler:
func handler(w http.ResponseWriter, r *http.Request) {
v := signing.VerifiedSignerFromContext(r.Context())
if v == nil {
// Operation not in RequiredFor and request was unsigned β proceed
// with bearer auth or whatever fallback you've configured.
}
// v.KeyID, v.AgentURL, v.VerifiedAt, v.Algorithm β available for audit.
}
For MCP servers: replace signing.DefaultOperationResolver with a custom resolver that parses the JSON-RPC envelope and returns the MCP tool name (the equivalent of mcpToolNameResolver in the TS SDK).
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
| Resolver | Use case |
|---|
StaticJwksResolver | Fixed set of known buyer keys. Good for dev/testing. |
HttpsJwksResolver | Fetches JWKS from a URL with caching and refresh. |
BrandJsonJwksResolver | Full 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).
JavaScript/TypeScript
Python
Go
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...
});
from adcp.signing import (
WebhookVerifyOptions,
BrandJsonJwksResolver,
InMemoryReplayStore,
verify_webhook_signature,
)
from adcp.signing.errors import SignatureVerificationError
webhook_options = WebhookVerifyOptions(
jwks_resolver=BrandJsonJwksResolver(),
replay_store=InMemoryReplayStore(),
)
@app.post("/webhook")
async def webhook(request: Request):
body = await request.body()
try:
sender = verify_webhook_signature(
method=request.method,
url=str(request.url),
headers=dict(request.headers),
body=body,
options=webhook_options,
)
except SignatureVerificationError:
raise HTTPException(status_code=401, detail="invalid webhook signature")
# sender.key_id, sender.agent_url available for audit; process the webhook.
import (
"github.com/adcontextprotocol/adcp-go/adcp/signing"
)
// Mount the same Middleware on your webhook receiver, but configure it for
// the webhook profile β adcp_use="webhook-signing", Content-Digest required,
// no required_for gating (webhooks always carry signatures).
webhookMW := signing.Middleware(signing.MiddlewareOptions{
Resolver: signing.NewBrandJSONJWKSResolver(),
Replay: signing.NewMemoryReplayStore(0),
Revocation: signing.NewStaticRevocationSource(nil),
Profile: signing.ProfileWebhookSigning,
ContentDigestPolicy: signing.DigestRequired,
})
http.Handle("/webhook", webhookMW(webhookHandler))
func webhookHandler(w http.ResponseWriter, r *http.Request) {
sender := signing.VerifiedSignerFromContext(r.Context())
// sender.KeyID, sender.AgentURL β process the verified webhook.
}
Step 6: Sign outbound webhooks (seller)
JavaScript/TypeScript
Python
Go
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: { /* ... */ },
}));
Sign each outbound webhook with sign_webhook and attach the returned headers before sending:from adcp.signing import sign_webhook, load_private_key_pem
import httpx, json
private_key = load_private_key_pem(open("webhook-private-key.pem", "rb").read())
async def post_webhook(url: str, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}
signed = sign_webhook(
method="POST",
url=url,
headers=headers,
body=body,
private_key=private_key,
key_id="my-seller-webhook-2026",
alg="ed25519",
)
headers.update(signed.as_dict()) # adds Signature, Signature-Input, Content-Digest
async with httpx.AsyncClient() as client:
await client.post(url, content=body, headers=headers)
Configure a Signer with ProfileWebhookSigning and use it via SignRequest or its RoundTripper:priv, _, _ := signing.LoadPrivateKey(webhookPemBytes)
webhookSigner, _ := signing.NewSigner(signing.SignerOptions{
KeyID: "my-seller-webhook-2026",
Algorithm: signing.AlgEd25519,
PrivateKey: priv,
Profile: signing.ProfileWebhookSigning,
})
webhookClient := &http.Client{
Transport: webhookSigner.RoundTripper(http.DefaultTransport, true /* always cover content-digest for webhooks */),
}
// Use webhookClient.Post / .Do to deliver webhooks; signatures are added automatically.
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:
JavaScript/TypeScript
Python
Go
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: { /* ... */ },
});
from adcp.server.responses import capabilities_response
class MySeller(ADCPHandler):
async def get_adcp_capabilities(self, params, context=None):
return capabilities_response(
["media_buy"],
request_signing={
"supported": True,
"required_for": ["create_media_buy", "update_media_buy"],
"supported_for": ["sync_creatives", "sync_audiences"],
"covers_content_digest": "either",
},
)
// In your get_adcp_capabilities handler, set the request_signing block on
// the response builder:
return adcp.CapabilitiesResponse(adcp.CapabilitiesData{
SupportedProtocols: []string{"media-buy"},
RequestSigning: &adcp.RequestSigningCapability{
Supported: true,
RequiredFor: []string{"create_media_buy", "update_media_buy"},
SupportedFor: []string{"sync_creatives", "sync_audiences"},
CoversContentDigest: "either",
},
}), nil
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:
- Generate a new keypair with a new
kid
- Add the new public key to JWKS (both old and new are published)
- Update signing configuration to use the new private key
- 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
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>":
| Code | Meaning |
|---|
missing_signature | Signature headers not present when required |
invalid_signature | Signature doesnβt verify against the public key |
expired_signature | Signature timestamp too old |
replayed_nonce | Nonce was already used |
revoked_key | Key has been revoked |
unknown_key | Key ID not found in JWKS |
unsupported_algorithm | Algorithm not in allowlist |
For the full error code taxonomy, see Transport error taxonomy.