Rolling out RFC 9421 request signing against an existing AdCP integration is a two-track exercise: bootstrap once, then enforce in stages per operation. Key rotation follows the same pattern and is meant to be routine.
This guide covers the operator-facing mechanics. Spec reference: Signed Requests (Transport Layer).
Signing agent's operator: For key discovery, the operator publishes the
brand.jsonlisting the signing agent and its keys. Discover it from the agent'sget_adcp_capabilitiesresponse (identity.brand_json_url), or configure it at onboarding. It never means theoperatororbrandin the request's account. These are different roles even when their domains coincide. Do not derive key discovery from request-body fields.
The Python SDK ships parallel ergonomics to adcp-go's MIGRATION guide — same staged rollout, same key-rotation pattern, different language idioms.
ADCPClient derives the signing wire profile from its trusted
server_version / adcp_version pin. An explicit
SigningConfig(signing_profile_version=...) overrides that selection. The
low-level sign_request() and async_sign_request() primitives require an
explicit profile because they do not participate in version negotiation.
The supported signing profiles are 3.0, 3.1, and 3.2. A signed client pinned
to an older AdCP version therefore fails at construction unless
signing_profile_version explicitly selects a supported profile; unsigned
legacy clients are unaffected.
One-time work to make an agent able to sign (as a buyer) or verify (as a seller).
adcp-keygen --alg ed25519 --kid my-agent-2026-01 --purpose request-signing \
--out signing.pemOr programmatically — the same spine the CLI uses:
from adcp.signing import generate_signing_keypair
pem, public_jwk = generate_signing_keypair(
alg="ed25519",
kid="my-agent-2026-01",
purpose="request-signing",
)Prefer Ed25519 over ES256 unless a regulatory constraint forces NIST curves. Ed25519 is deterministic by construction — no RNG participates at sign time, which simplifies replay analysis.
The command writes signing.pem (PKCS#8 private key) and prints/returns a JWK with use: "sig", key_ops: ["verify"], and adcp_use: "request-signing". Publish the JWK at your agent's jwks_uri:
{ "keys": [ { "kid": "my-agent-2026-01", "kty": "OKP", "crv": "Ed25519", "x": "...", "use": "sig", "key_ops": ["verify"], "adcp_use": "request-signing" } ] }Hold the PEM in your secret store — environment variable, GCP Secret Manager, AWS Secrets Manager. Never commit it.
Set request_signing on your capabilities response with empty supported_for / warn_for / required_for to start. Counterparties probing your capabilities can now see the block exists.
from adcp.types.capabilities import RequestSigning
request_signing = RequestSigning(
supported=True,
covers_content_digest="either", # "required" | "forbidden" | "either"
required_for=[],
warn_for=[],
supported_for=[],
)Start from the signing agent's URL in your trusted counterparty configuration.
Use await verify_from_agent_url(request, agent_url=buyer_agent_url, operation="create_media_buy", replay_store=replay_store) to discover its
operator record and verify a request, or await async_resolve_agent(buyer_agent_url)
for discovery alone. These helpers read identity.brand_json_url from
get_adcp_capabilities and enforce agent/operator origin binding and declared
key origins. See the agent discovery examples.
For onboarded counterparties using framework verification, configure
JwksUriSignerKeys({agent_url: jwks_uri}) or
StaticSignerKeys({agent_url: {"keys": [public_jwk]}}) with the signing agent's
URL as the key and endpoints or public keys obtained at onboarding.
When these mappings replace discovery under the 3.3 profile, establish or
reconfirm the agent-to-operator mapping against the agent's
identity.brand_json_url and reconfirm it within the brand.json cache lifetime.
These resolvers do not perform that discovery or refresh automatically; see the
onboarding guidance.
Direct BrandJsonJwksResolver construction is a lower-level option for a
brand.json record you already trust. It does not perform capabilities-based
origin binding. Never construct its URL from account.operator or
account.brand; see direct resolver construction.
A seller built on adcp.server.serve / adcp.decisioning.serve does not need to hand-wire the verifier. Opt in, and the framework verifies every JSON-RPC POST on the MCP and A2A legs before dispatch, enforcing the request_signing block you advertise:
from adcp.decisioning import serve
from adcp.signing import JwksUriSignerKeys, PgReplayStore
serve(
platform, # platform.capabilities.request_signing is the enforced policy
signer_keys=JwksUriSignerKeys({
"https://buying-agent.example.com/mcp": "https://buying-agent.example.com/.well-known/jwks.json",
}),
signature_replay_store=PgReplayStore(pool), # shared across replicas
buyer_agent_registry=registry,
)On adcp.server.serve, pass the equivalent config explicitly:
from adcp.server import RequestSignatureVerification, serve
serve(
handler,
request_signature_verification=RequestSignatureVerification.from_capability(
request_signing, # the same block get_adcp_capabilities returns
signer_keys=signer_keys,
replay_store=replay_store,
),
)What the framework does:
- Unsigned request to a
required_foroperation (or a JSON-RPC method inprotocol_methods_required_for, e.g.tasks/cancel) →401withWWW-Authenticate: Signature error="request_signature_required". Buyers can tell "you didn't sign" apart from "we don't know you". - Presented signature → verified against the key the
SignerKeyResolvermaps thekeyidto; a failure is401with the spec error code.warn_foroperations log the failure and continue without identity, except a partial or malformedSignature/Signature-Inputpair, which always rejects. - Verified signer →
ToolContext.caller_identityis the buyer's agent URL andctx.auth_infoisAuthInfo(kind="http_sig", credential=HttpSigCredential(...)), soBuyerAgentRegistry.resolve_by_agent_urlresolves the buyer with no seller glue. A signed request needs no bearer token even whenauth=is configured.
keyid → agent URL. The verifier checks a signature against a key; it does not know which agent owns that key. A SignerKeyResolver supplies that mapping explicitly: StaticSignerKeys({agent_url: jwks}) for inline keys, JwksUriSignerKeys({agent_url: jwks_uri}) for published endpoints, or your own async callable returning ResolvedSignerKey(agent_url, jwk). Both shipped resolvers map only the agents you configure; discovering an unknown signer's agent URL is an open spec question (adcontextprotocol/adcp#7814).
Replay store. One store serves every request. The default is a per-process InMemoryReplayStore, which cannot see nonces claimed by other replicas; with ADCP_ENV=production it is refused unless you pass a shared store or set ADCP_ALLOW_INMEMORY_REPLAY_STORE=1 for a single-process deployment.
Content digest. The enforced covers_content_digest is the value you declare; declare "required" for AdCP 3.2. When it is omitted, enforcement uses the legacy "either".
Already verifying in your own middleware? verify_starlette_request records its result on the request scope and the framework reuses it rather than claiming the nonce a second time. If you call verify_request_signature directly, remove that middleware when you opt in — otherwise the framework sees the nonce as already used and rejects every request as request_signature_replayed.
Behind a TLS terminator, the signed @target-uri is the public https:// URL. serve() runs uvicorn with proxy-header support, which trusts X-Forwarded-Proto / X-Forwarded-For only from FORWARDED_ALLOW_IPS (default 127.0.0.1). Set FORWARDED_ALLOW_IPS to your proxy's address so the ASGI scope reflects the public scheme; otherwise every signature fails as request_signature_invalid.
Current limits:
- Bearer fallback on
required_foris opt-in. By default a required operation rejects every unsigned request, including ones with a valid bearer token. The spec lets a seller accept such a request when a configured fallback authenticator succeeds. Enable it withRequestSignatureVerification(allow_bearer_fallback=True)(oradcp.decisioning.serve(signature_bearer_fallback=True)), together withauth=BearerTokenAuth(...). An unsigned required request then proceeds only with a valid bearer. The discovery andallow_unauthenticatedbypasses don't apply to it, and a failure returns401withWWW-Authenticate: Signature error="request_signature_required"alone — the signing profile forbids other parameters on that challenge, so noBearerchallenge is appended. This lets bearer-only counterparties keep working while you move an operation torequired_for. - Shadow mode observes; it doesn't authenticate. A
warn_forrequest with a missing or failed signature is logged and continues without signer identity. It is then authenticated exactly as it would be with no signing configured: bearer auth (including its discovery andallow_unauthenticatedbypasses) ifauth=is set, and nothing otherwise. The same applies to unsigned requests to operations in no list. - Mixed credentials. When a request carries both a bearer token and a valid signature, the signer becomes
caller_identityandctx.auth_info. A customcontext_factorythat sets a typed credential keeps ownership of identity. - Transports. Framework verification runs on streamable-HTTP MCP and A2A (including
transport="both"). It is ignored, with a warning, on stdio and the legacy SSE transport.
The staged rollout below applies unchanged — each step is a change to the request_signing block you advertise, and the framework enforces it. The hand-wired snippets are for servers that don't use serve().
Never flip an operation straight from unsigned to required. Stage it through three stops.
Add the operation to supported_for. Counterparties MAY sign; your verifier MUST accept signed requests but does not yet reject unsigned ones.
The Python middleware stays permissive — wire replay_store and jwks_resolver, but leave required_for empty. The lower-level example below uses a signing agent's JWKS endpoint obtained at onboarding:
from adcp.signing import (
CachingJwksResolver,
InMemoryReplayStore,
VerifierCapability,
VerifyOptions,
verify_starlette_request,
)
jwks_resolver = CachingJwksResolver(jwks_uri="https://buying-agent.example.com/.well-known/jwks.json")
# Build the replay store ONCE and share it. `VerifyOptions` is per request
# (`now` changes), and omitting `replay_store` gives each options instance
# its own fresh store — which silently disables replay protection. Use a
# shared store (e.g. PgReplayStore) for multi-replica setups.
replay_store = InMemoryReplayStore()
options = VerifyOptions(
now=time.time(),
capability=VerifierCapability(
covers_content_digest="either",
required_for=frozenset(), # nothing rejected yet
supported_for=frozenset({"create_media_buy"}),
),
operation="create_media_buy",
jwks_resolver=jwks_resolver,
replay_store=replay_store,
)
verified = await verify_starlette_request(request, options=options)
# verified.key_id is the buyer's signing identity.Success signal: signed requests arrive, verify_starlette_request returns a VerifiedSigner rather than raising.
Move the operation to warn_for. Verification still runs, failures are logged, traffic is unaffected. Watch your failure rate and walk down the long tail of "some counterparty is misbehaving" before flipping to reject.
The spec calls this shadow mode. Framework verification implements it from warn_for directly. When hand-wiring, approximate it by catching SignatureVerificationError and logging:
from adcp.signing import SignatureVerificationError
try:
verified = await verify_starlette_request(request, options=options)
except SignatureVerificationError as exc:
# WARNING: step-B shim only. Delete before enabling required_for in step C —
# this turns every required-signed op into an unsigned op.
logger.warning(
"signature would reject", extra={"code": exc.code, "step": exc.step}
)
verified = None # fall through unsignedSuccess signal: search your logs for "signature would reject" and watch the rate fall to zero — or to a known-and-tolerated set of counterparties — over a window long enough to cover your slowest integrator's deploy cadence.
Move the operation to required_for and populate VerifierCapability.required_for. Don't enable covers_content_digest="required" yet — body-modifying intermediaries are the most common surprise failure in production rollouts and they only break the digest path. Land required_for first:
options = VerifyOptions(
now=time.time(),
capability=VerifierCapability(
covers_content_digest="either",
required_for=frozenset({"create_media_buy"}),
),
operation="create_media_buy",
jwks_resolver=jwks_resolver,
replay_store=replay_store,
)
# Remove the step-B shim. Let SignatureVerificationError propagate to your
# 401 response builder.
try:
verified = await verify_starlette_request(request, options=options)
except SignatureVerificationError as exc:
return JSONResponse(
status_code=401,
headers=unauthorized_response_headers(exc),
content={"error": exc.code, "message": str(exc)},
)A rollout later, once you've confirmed no request_signature_digest_mismatch in step-B logs, tighten to covers_content_digest="required":
capability=VerifierCapability(
covers_content_digest="required",
required_for=frozenset({"create_media_buy"}),
)If production breaks after flipping required_for:
- Revert the verifier config — drop the operation from
VerifierCapability.required_for(and fromcovers_content_digest="required"if set). Redeploy. - Do not touch
jwks_urior your revocation list. Counterparties that are already signing correctly will keep doing so, harmlessly. - Update
get_adcp_capabilities.request_signing.required_foron the next deploy to match the rolled-back verifier — counterparties probing capabilities must not be told "required" while the verifier is back to permissive.
Returning to step B's shadow mode is the safe resting state while you diagnose.
Schedule rotation routinely — monthly to quarterly is the common range — so the path is exercised and a compromise-driven rotation isn't the first time you run it.
Two JWKS publishes plus a signer cutover:
- Publish new kid alongside old kid. Update
jwks_urito list both keys. Wait for counterparties' JWKS caches to refresh —CachingJwksResolverholds a 30-second refetch cooldown on kid-miss, so one minute is a safe floor. - Cut over the signer. Flip
SigningConfig.key_id/SigningConfig.private_keyon every instance. Adapters usingADCPClient(signing=...)reconstruct the client with the new config; adapters usinginstall_signing_event_hookrecreate the hook against the newSigningConfig. - Grace period. Hold both kids in the JWKS for at least 2× the max validity window (10 minutes with the default 300-second window). Reasoning: one window for the last request you signed under the old kid to reach its
expires, plus one window for its replay-cache entry to age out so you can't distinguish a real replay from a drained entry. Add operational headroom on top (1–2× the deploy cadence of your slowest counterparty) so in-flight retries under the old kid still verify. - Remove the old kid. Publish a JWKS with only the new kid.
Ordering is different — the old kid must stop being trusted before anything else happens, because during routine step 1 ("publish both"), counterparties still accept signatures under the old kid for the full JWKS-propagation window:
- Revoke first. Add the burned kid to your revocation list with a timestamp covering the compromise window. Counterparties polling the revocation list (via
CachingRevocationChecker) will reject anything signed by the burned kid, even if their JWKS cache hasn't refreshed yet. - Publish the new kid. Update
jwks_urito include the new kid. The old kid can stay in the JWKS or be removed immediately — revocation beats presence. - Cut over the signer to the new kid on every instance.
- Remove the old kid from the JWKS once the compromise-window revocation entry is no longer needed. Keep the revocation entry active for at least the historical audit window (whatever your governance requires).
- Resolving keys from the account's operator or brand.
account.operatornames the entity operating the account, such as an agency or an advertiser operating directly;account.brandidentifies the advertiser. Neither selects the signing agent's keys, even when the domains coincide. Building aBrandJsonJwksResolverURL from these fields can resolve an unrelated record and reject requests withrequest_signature_jwks_untrusted. Use agent discovery or key mappings obtained at onboarding instead. - Body-modifying intermediaries break
content-digestcoverage. CDNs, WAFs, and API gateways that recompress or re-serialize request bodies causerequest_signature_digest_mismatch. Diagnose by comparing signer-side body bytes to verifier-side body bytes — they must be byte-identical. Either preserve bytes end-to-end or stay oncovers_content_digest="either"for the affected operation. - Forgetting to disable redirect-following on signed clients.
@target-uriis part of the signature base. If the server returns a 3xx redirect, the signature still binds to the original URL. Configurehttpx.AsyncClient(follow_redirects=False)or implement a redirect handler that re-signs. - Clock skew > 60s. Verifiers reject with
request_signature_window_invalidwhencreatedis more thanmax_skew_secondsin the future orexpiresis past. NTP-sync both sides; investigate container hosts that drift after suspend/resume. - Custom JWKS fetcher losing SSRF protection. If you implement a custom
JwksFetcher, gate it throughvalidate_jwks_uri/resolve_and_validate_host— otherwise an attacker who controls ajwks_urican pivot against your internal network. - Per-keyid replay cap. The default
InMemoryReplayStorecaps at 1,000,000 entries per keyid (configurable viaper_keyid_cap). Sustained > 3k QPS per signing key will triprequest_signature_rate_abuse. For multi-process deployments, usePgReplayStore(the[pg]extra) or roll your own backing theReplayStoreProtocol. - Step-B shim surviving into production. The
try/except SignatureVerificationError → log → fall throughpattern from step B silently turns every required-signed op into an unsigned op oncerequired_foris set. Before flipping, grep your codebase forSignatureVerificationErrorand confirm the shim is gone. Months later, if you inherit the repo, re-grep. current_operationContextVar leaking into background tasks.signing_operation()sets a ContextVar that copies intoasyncio.create_taskchildren. Don't spawn unrelated network calls inside a signing scope — they'd inherit the operation name and sign requests the caller didn't intend to sign.
- At least one signed request from a staging counterparty has been verified end-to-end —
verify_starlette_requestreturns aVerifiedSignerin your handler — before flippingrequired_for. -
jwks_urireturns your current kid (and only your current kid, once rotation is complete). -
get_adcp_capabilities.request_signing.required_formatchesVerifierCapability.required_for. - Revocation source is configured (
revocation_checkerorrevocation_listis non-None) if you've published any revocations — otherwise revoked keys are accepted silently. - Replay store strategy is explicit: default
InMemoryReplayStorefor single-instance,PgReplayStore(or equivalent shared store) for multi-instance. - Logs from step B show zero unexpected failures over at least one full deploy cycle of your slowest counterparty.
- No step-B shim (
try/except SignatureVerificationError → log → continue) remains in the verifier wiring. - Redirect-following is disabled on every signing client.
- Clock sync monitoring is in place on signer and verifier hosts.
canonicalize_target_uri substitutes / for an empty path whenever an authority is present, which is what step 5 of the AdCP URL-canonicalization algorithm and RFC 3986 §6.2.3 require. Earlier releases applied the substitution only when the URL also carried a query string, so an empty path kept its emptiness unless a ?x=1 followed it:
| URL dialled | Earlier releases | This release |
|---|---|---|
https://seller.example.com |
https://seller.example.com |
https://seller.example.com/ |
https://seller.example.com:8443 |
https://seller.example.com:8443 |
https://seller.example.com:8443/ |
https://[2001:db8::1] |
https://[2001:db8::1] |
https://[2001:db8::1]/ |
http://seller.example.com:80 |
http://seller.example.com |
http://seller.example.com/ |
https://seller.example.com? |
https://seller.example.com? |
https://seller.example.com/? |
A URL with any non-empty path, and https://seller.example.com?x=1, canonicalize exactly as they did before.
@target-uri is part of the signature base, so for the URLs above the signed bytes change. A signer on this release and a verifier on an earlier one — or the reverse — derive different bases for the same request, and verification fails with request_signature_invalid. Nothing in that error names canonicalization, so sequence the upgrade rather than diagnosing it afterwards.
Two ways across:
- Dial the explicit root path.
https://seller.example.com/canonicalizes tohttps://seller.example.com/on every release, before and after this change. Point signed clients at the URL with its path spelled out and both versions derive the same base, so signer and verifier upgrade independently and in any order. Worth making permanent: an agent URL registered with its path never depended on the fix. - Upgrade both ends together. For an agent URL you cannot respell, move its signer and its verifier in one window the way you would a key rotation, or hold the affected operation at
warn_for(§2, step B) across the window so a base mismatch logs instead of rejecting.
The same function is the identifier comparer for adagents.json authorized_agents[].url, brand.json agents[].url, TMP seller_agent.agent_url, format-id.agent_url and the governance issuer. There the change is strictly more permissive: https://agent.example.com and https://agent.example.com/ now compare equal, where before a publisher who registered the first did not match a caller who arrived as the second, and the refusal named authorization rather than canonicalization. No registration needs editing, and no pair that matched before stops matching.
adcp.signing.install_signing_event_hook— buyer-side preset for adapters not usingADCPClient.ADCPClient(signing=...)— the higher-level client integration; signing is wired automatically once you pass aSigningConfig.- adcp-go's MIGRATION.md — Go sibling guide.
- AdCP signing guide (docs PR) — the cross-language end-user walkthrough.