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).
The Python SDK ships parallel ergonomics to adcp-go's MIGRATION guide — same staged rollout, same key-rotation pattern, different language idioms.
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.generated_poc.protocol.get_adcp_capabilities_response import (
CoversContentDigest,
RequestSigning,
)
request_signing = RequestSigning(
supported=True,
covers_content_digest=CoversContentDigest.either,
required_for=[],
warn_for=[],
supported_for=[],
)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:
from adcp.signing import (
CachingJwksResolver,
InMemoryReplayStore,
VerifierCapability,
VerifyOptions,
verify_starlette_request,
)
jwks_resolver = CachingJwksResolver(jwks_uri="https://buyer.example.com/.well-known/jwks.json")
# `replay_store` defaults to a fresh InMemoryReplayStore — single-process
# deployments don't need to wire one explicitly. Pass an explicit shared
# store (Redis-backed, custom Postgres) for multi-replica setups.
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,
)
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. Python doesn't have a built-in observe_only flag yet — approximate 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,
)
# 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).
- 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.
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.