Looking for the why? This page is the normative implementation reference — the rules a compliant agent follows. For the threat model, the layered defense narrative, and a checklist for brand IT and CISOs, see the Security Model.
Overview
AdCP operates in a high-stakes environment where:- Financial transactions involve real advertising spend
- Multi-party trust requires coordination between authenticated agents, publishers, and orchestrators
- Sensitive data includes first-party signals, pre-launch creatives, and competitive targeting strategies
- Asynchronous operations span multiple systems and protocols
Risk Classification
High-Risk Operations (Financial)
These operations commit real advertising budgets:| Operation | Risk | Primary Threat |
|---|---|---|
create_media_buy | Creates financial commitments | Budget fraud, credential theft |
update_media_buy | Modifies budgets and campaign parameters | Unauthorized modifications |
- Short-lived credentials — right-sized to the blast radius of a leaked token. ≤1 hour is a reasonable default for tokens that can commit spend; ≤15 minutes is appropriate for tokens that can commit spend above a material threshold or that cross organizational boundaries. Document and justify the chosen window rather than defaulting to the lowest number.
- Request signing for transaction integrity
- Multi-factor authentication or approval workflows for large budgets
- Full audit trail with immutable logging
Medium-Risk Operations (Data Access)
These operations access sensitive business data:| Operation | Risk |
|---|---|
get_media_buy_delivery | Exposes performance metrics and spend data |
list_creatives | Access to creative assets |
sync_creatives | Uploads potentially sensitive creative content |
Low-Risk Operations (Discovery)
These operations are publicly accessible:| Operation | Risk |
|---|---|
get_adcp_capabilities | Agent capability discovery |
get_products | Public inventory discovery |
list_creative_formats | Public format catalog |
Webhook Security
AdCP 3.0 unifies webhook signing on the AdCP RFC 9421 profile — the seller signs outbound webhooks with a key published through its operatorbrand.json agents[].jwks_uri, and the buyer verifies against that JWKS. When the publisher’s adagents.json pins signing_keys[] for that seller, the pin is authoritative. Nothing secret crosses the wire; identity is cryptographically established the same way it is for inbound requests.
9421 webhook signing is baseline-required in 3.0. Any seller that emits webhooks MUST sign them per the Webhook callbacks profile unless the buyer explicitly opts into the legacy scheme below by populating push_notification_config.authentication or accounts[].notification_configs[].authentication.
Legacy HMAC-SHA256 fallback (deprecated, removed in 4.0)
Buyers who need to interoperate with receivers that have not yet adopted the 9421 profile MAY opt in by populatingpush_notification_config.authentication.credentials or accounts[].notification_configs[].authentication.credentials. When authentication is present on the buyer’s request, the seller signs with HMAC-SHA256 using the semantics defined in Push Notifications. The legacy scheme is a 3.x-only compatibility affordance; sellers MAY decline to support it, and it is removed in AdCP 4.0.
Normative rules for the legacy scheme when a seller elects to support it:
- Algorithm: HMAC-SHA256 only
- Signed message:
{unix_timestamp}.{raw_http_body_bytes}— never re-serialize the JSON - Byte-equality invariant: The HMAC is computed over raw bytes, not over a parsed JSON value. Signers and verifiers MUST compare the bytes on the wire directly; re-parsing and re-serializing a payload — even with matching libraries and compact separators — is not guaranteed to reproduce the signed bytes, because key ordering, unicode-escape policy, and number representation all diverge across serializers (see “Non-canonicalized aspects” below for concrete examples). This scheme does not define a canonical JSON form; the “Canonical on-wire form” and “Verifier input” rules below narrow the most common byte-drift failures on the signer and verifier sides respectively, but do not eliminate byte-level divergence.
- Canonical on-wire form: The
{raw_http_body_bytes}MUST be byte-identical to the bytes the signer puts on the wire as the HTTP body. When the signer constructs the body by serializing a JSON value, it MUST use the JSON compact separators","(item separator) and":"(key separator) — no whitespace between tokens. The language-level serializers JavaScriptJSON.stringify, Goencoding/jsonjson.Marshal, RubyJSON.generate, and Java JacksonwriteValueAsStringproduce compact output by default; HTTP clients that wrap them (axios, Gonet/httpwith ajson.Marshal-ed body, RubyNet::HTTPwithJSON.generate, Java OkHttp with Jackson) inherit those defaults. In Python,httpxserializes with compact separators, but stdlibjson.dumpsdefaults to", "/": "and HTTP clients that hand their payload tojson.dumpswithout aseparatorskwarg (requests(json=...),aiohttp) emit spaced bodies — signers on those paths MUST passseparators=(",", ":")explicitly. This enumeration is non-exhaustive; signers MUST verify their HTTP client’s actual on-wire serialization (e.g., capture the request body via a proxy or hook) rather than rely on this list. The signature covers the bytes the receiver sees, not the object the signer serialized. - Non-canonicalized aspects: Key ordering, unicode-escape policy, and number representation are NOT canonicalized by this scheme. For numbers in particular, language defaults diverge (
JSON.stringify(1.0)→1, Pythonjson.dumps(1.0)→1.0, Gojson.Marshal(1.0)→1; floats like0.1and scientific notation hit similar cliffs), so a signer that serializes with one library and then re-parses / re-serializes with another before sending can produce signer-verifier drift even with compact separators — the byte-equality invariant above is the only thing that holds the scheme together. - Duplicate object keys: Signers MUST NOT emit duplicate object keys AND MUST reject duplicate-key input from upstream callers before serialization. The signer-side MUST is load-bearing because it is the only place this failure mode can be caught: a signer that silently collapses a duplicate-key payload emits a cryptographically-clean signed frame whose semantics differ from the caller’s intent, and the verifier cannot detect the upstream divergence from the wire — the signed bytes look normal. Signer-side conformance is unverifiable on the wire and is expected to be enforced by out-of-band audit / interop testing, not runtime detection (this shape is routine in signing specs; COSE and JOSE use the same pattern). Verifiers MUST reject bodies containing duplicate object keys after HMAC verification succeeds, returning a structured malformed-body error (distinct from a signature-mismatch error — the signature IS valid; the body is malformed). Per RFC 8259 §4, the names within a JSON object “SHOULD be unique” and the behavior of software that receives an object with non-unique names is unpredictable — so two verifiers parsing the same HMAC-valid bytes can disagree on the parsed value. This is a parser-differential attack class (cf. CVE-2017-12635 where one CouchDB parser read
roles=[]and another readroles=["_admin"]from the same signed body). Every body carried on the legacy HMAC webhook scheme is a state-change notification (creative status, media-buy status, governance transitions), so the MUST applies unconditionally to this scheme. The detection MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. Per-language strict-parse escape hatches for both signer input-validation and verifier body-checking: see step 14 of the webhook verifier checklist for the canonical non-exhaustive enumeration, including the libraries that only appear strict by default but silently collapse data-key duplicates. The verifier-side conformance fixture isduplicate-keys-conflicting-valuesinstatic/test-vectors/webhook-hmac-sha256.json, withexpected_verifier_action: "reject-malformed". Signer-side conformance fixtures live in the same file undersigner_side.rejection_vectors:signer-upstream-duplicate-key-rejection(top-level),signer-upstream-duplicate-key-deep-nested(verifies the signer’s check recurses into nested objects, not only top-level keys),signer-upstream-duplicate-key-array-contained(verifies the signer’s check descends into objects inside arrays — a blind spot in hand-rolled validators that recurse into objects but not array members), andsigner-upstream-duplicate-key-three-deep(verifies the walker does not halt at a shallow fixed depth). A positive-case fixturesigner-upstream-clean-inputlives undersigner_side.positive_vectorsso that a signer rejecting everything does not trivially pass the negative fixtures — interop harnesses MUST assert both rejection of the duplicate-key inputs and acceptance of the clean input. Signers that surface upstream-input rejections via logs or error responses MUST apply the same key-name sanitization rules defined in step 14b of the webhook verifier checklist (truncate at first non-printable to<sanitized:N>, truncate to last UTF-8 codepoint at or below 32 bytes, cap count at 4) — the signer-side channel has the same attacker-controlled-byte shape as the verifier-side channel, just with the direction of trust inverted. Error identifier is normative; error-object internals are not. When a signer surfaces the rejection via an error, the error identifier (error-code string in a discriminated union, exception class name in typed-throw idioms, tag in a sum type) MUST beduplicate_key_inputexactly — case-sensitive, no prefix or suffix — so that multi-SDK integrations can writeif (error.code === 'duplicate_key_input') { ... }and have the dispatch work regardless of which SDK signed the frame. The internal shape of the error carrier (field names for the sanitized key list, overflow-marker string, typed-exception constructor arguments) is implementation-defined. Verifiers that crash / fail-closed are conformant-but-suboptimal (the request is not silently accepted, but senders receive no actionable error code); verifiers SHOULD return a structured malformed-body error instead. The non-conformant failure mode — silent accept where the signature verifier’s parse diverges from the downstream business-logic parse — is now forbidden; a verifier that does not detect duplicate keys before handing the payload to business logic does not conform to this scheme. - Verifier input: Verifiers MUST use the raw HTTP body bytes as received on the wire, captured before any JSON parse or re-serialize. Every modern HTTP framework exposes a pre-parse raw-body hook (Express
express.raw(), FastAPIRequest.body(), aiohttpRequest.read(), Goio.ReadAll(r.Body)beforejson.Unmarshal). The raw-capture hook MUST run before any JSON-parse middleware on the same route; a globally-mountedexpress.json()or FastAPIBaseModelbody binding that consumes the request body before the verifier runs leaves the verifier operating on a re-stringified payload, not the signed bytes — this is a common deployment mistake. Verifiers SHOULD NOT re-serialize a parsed payload to reconstruct the signed bytes: re-serialization silently fails against signers whose output differs in key order, unicode escapes, or number formatting, and masks signer bugs the verifier should surface. A verifier that genuinely cannot capture raw bytes MUST fail closed and surface the infrastructure gap rather than accept a re-serialized approximation. - Timestamp source: The
{unix_timestamp}in the signed message MUST be the exact ASCII integer sent in theX-ADCP-Timestampheader. Signers and verifiers MUST NOT derive it from any body field. - Timing-safe comparison: MUST use constant-time comparison (e.g.,
timingSafeEqual) - Replay window: Reject requests where
|current_time - timestamp| > 300seconds - Minimum secret length: 32 bytes
- Header format:
X-ADCP-Signature: sha256=<hex digest>andX-ADCP-Timestamp: <unix seconds>. Any body-levelsignaturefield is a convenience copy and MUST NOT be trusted over the headers.
- Reject if
X-ADCP-SignatureorX-ADCP-Timestampheader is missing - Reject if timestamp is non-numeric
- Reject if timestamp is outside the 5-minute window
- Compute and compare HMAC
- Receivers MUST accept signatures from both current and previous secret during rotation
- Rotation window SHOULD NOT exceed the replay window (5 minutes)
- Publishers begin signing with the new secret immediately upon rotation
Webhook URL validation (SSRF)
Any URL that a buyer, seller, or governance agent provides for another party to fetch is an SSRF vector. This includespush_notification_config.url, accounts[].notification_configs[].url, collection-list webhook_url, TMP provider endpoint, adagents.json authoritative_location, and reporting_bucket.setup_instructions.
Account-level webhook subscribers registered through sync_accounts.accounts[].notification_configs[] also require endpoint ownership proof before activation. SSRF validation proves the seller is not calling an internal network address; it does not prove the buyer controls the public HTTPS endpoint. Sellers MUST complete an RFC 9421-signed activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active, and the receiver MUST verify the seller identity, delivery auth metadata, and event type set before echoing the challenge. Paused subscribers (active: false) may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and paused subscribers MUST NOT receive fires until reactivated. The standard challenge payload and response shape are defined in sync_accounts endpoint proof of control.
Before any outbound fetch to a counterparty-controlled URL, fetchers MUST:
- Reject non-HTTPS URLs in production.
- Resolve the hostname and reject the fetch if the resolved IP falls in any reserved range:
- IPv4: RFC 1918 (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), RFC 6598 CGNAT (100.64.0.0/10), loopback (127.0.0.0/8), link-local (169.254.0.0/16— explicitly includes169.254.169.254used by AWS/GCP/Azure/Alibaba instance metadata), broadcast (255.255.255.255),0.0.0.0/8, multicast (224.0.0.0/4). - IPv6: loopback (
::1), unique-local (fc00::/7), link-local (fe80::/10), IPv4-mapped (::ffff:0:0/96— the most common bypass, mapping reserved IPv4 into IPv6), multicast (ff00::/8), and the AWS IMDSv2 fd00:ec2::254 address.
- IPv4: RFC 1918 (
- Pin the connection to the validated IP. DNS-based filtering alone is vulnerable to DNS rebinding: an attacker serves a public IP at validation time and a private IP at connect time. Fetchers MUST pin the connection. Preferred: (a) pass the validated IP directly to the TCP connect call and set the
Host:header from the URL. Fallback (only when the HTTP client cannot accept a pre-resolved IP): (b) validate the socket’s post-handshake peer address against the reserved-range list before sending any request body. Note: (b) depends on the client library exposing a peer-address hook that fires before the first body byte ships; many common libraries do not, so implementations choosing (b) MUST verify the hook in testing. Re-resolving DNS without pinning is not sufficient. - Refuse to follow redirects when fetching counterparty-controlled URLs (a 30x response lets the origin redirect to a reserved address that bypassed the initial check).
- Cap response size and timeouts. Recommended: 5 MB body cap, 10 s connect, 10 s read. The only exception is the dereferenced authoritative file in the managed-network indirection pattern — second-hop only, after a pointer file’s
authoritative_locationredirects to the network origin — which uses a recommended 20 MB cap because it fans out across a publisher network. Pointer files themselves stay at 5 MB. See managed networks security. - Do not echo fetch errors to the agent that supplied the URL. Detailed error messages (connection refused vs. timed out vs. TLS failure) are a side-channel for probing internal network topology.
Destination port: permissive by default
Publishers SHOULD NOT enforce a destination-port allowlist on counterparty-supplied URLs (push_notification_config.url, collection-list webhook_url, TMP provider endpoint, etc.) by default. The URL contract is format: "uri" only; the protocol does not constrain ports. Buyers legitimately host webhook receivers on non-standard TLS ports — Tomcat default :9443, Spring Boot default :4443, path-routed multi-tenant gateways, and per-tenant subdomains-with-port carve-outs — and a default port allowlist silently rejects them with no recourse short of asking the publisher operator to widen the list.
The SSRF guard the protocol relies on is the IP-range check + DNS-rebinding-resistant connect pin in steps 2–3 above, not port filtering. The reserved-range check covers the realistic SSRF threat (smuggling traffic to internal services on 10.0.0.0/8, 127.0.0.0/8, 169.254.169.254, etc.); port filtering on top of a routable public IP is a marginal defense whose cost (rejecting conformant buyers) typically exceeds its benefit.
Operators who want a destination-port allowlist as defense-in-depth — for example, locked-down enterprise environments where the publisher’s egress firewall already restricts outbound ports — SHOULD opt in explicitly via SDK or deployment configuration, with {443, 8443} as a reasonable hardened-mode starting point. SDKs that ship a DEFAULT_ALLOWED_PORTS constant MUST default it to “no restriction” and surface {443, 8443} as an opt-in profile, never as a default. Sellers that activate hardened mode MUST document the allowed-port set in their operator-facing documentation so buyers can size their integration before discovering the constraint at first-webhook-delivery time.
The wire-level URL contract is unconstrained beyond format: "uri"; hardened-mode port filtering is an operator-side policy choice, not a protocol-side requirement.
Feature-specific security sections extend these rules with their own lifecycle and content-handling requirements:
- Offline reporting buckets — IAM-layer prefix scoping, credential revocation on account status change.
- Collection lists —
auth_tokenscope and revocation, distribution-ID validation, webhook signature normative rules. - Managed networks
authoritative_location— validator fetch semantics, change detection, relationship termination. - TMP provider registration — dynamic registration authentication, router-to-provider auth,
/healthinfo-leakage rules.
Authentication Best Practices
Credential Storage
Token Expiration
Use short-lived tokens for high-risk operations:Agent and Account Isolation
Every piece of state — media buys, creatives, idempotency cache entries, session IDs, governance tokens — is scoped to the account that owns it. Cross-account reads MUST return a generic “not found” rather than leak existence. The authenticated agent is how the seller knows who is calling; theaccount on the request is what billing relationship the call is acting on. Isolation requires both checks.
Sales agents MUST:
- Bind on create — permanently associate each object (media buy, creative, session, etc.) with the account used on the request that created it.
- Verify on access — on every subsequent read or modification, verify the authenticated agent has access to the object’s bound account.
- Fail closed — when verification fails, return a generic error (status 403 or 404 is acceptable, but the body MUST NOT distinguish “unauthorized” from “not found” or name the account). Never fall through to the resource query.
The two-step pattern
Every account-scoped request whose schema requiresaccount carries an explicit AccountRef (via account_id for account-id namespaces, or the {brand, operator} natural key for buyer-declared accounts). A seller MUST NOT silently replace a missing required account with a credential-implied default. For tasks where account is optional, omission semantics are task-local and must be documented by that task. Correct isolation is two checks, performed in order:
- Auth precheck — the request’s
accountMUST be in the authenticated agent’s authorized set. Fail closed with a 403 or a generic “not found” (never “you are not authorized for that account” — that’s an existence leak). - Resource query — filter by the request’s
account_idas the primary key constraint. Not by the whole authorized set — only by the specific account this request is acting on.
get_media_buy(X) issued under account A would succeed for a buy owned by account B if both are in the agent’s authorized set. The request-supplied account_id is what ties a lookup to the caller’s stated intent.
Row-Level Security
The most common isolation failure is IDOR via joined or nested relations: a query scopes the primary table byaccount_id but joins or returns fields from a related table (line items, creatives, delivery rows) that was never filtered by the same principal. Defend per-principal at the data layer, not just in handler code, so a bug in one handler cannot punch through the wall:
get_media_buys without an explicit account filter), RLS scopes to the agent’s authorized set via a session variable populated at auth time:
Client-side isolation: cross-principal tool-call confusion
The rules above are server-side enforcement. They protect the seller’s data even when a legitimate-but-compromised agent is the caller. The client-side companion is the buyer agent’s obligation not to let text supplied by principal X drive tool calls that use principal Y’s authority. An LLM-driven buyer agent typically holds credentials for multiple principals at once: several sellers (one credential set per seller) and, inside an agency agent, several brand accounts. Any untrusted string the agent processes — product descriptions returned by a seller, campaign names inherited from a brief, rejection reasons in an error envelope, webhook event bodies — is text sourced from one of those principals. If the agent’s planning loop can call tools across all of them from a single LLM context, a prompt injected in seller X’s text can cause the agent to callcreate_media_buy on seller Y’s endpoint, or to spend brand A’s budget on brand B’s inventory. This is the confused-deputy problem at tool-call granularity: the attacker doesn’t need to escape the sandbox — the agent’s own legitimate authority does the damage.
Operators running LLM-powered AdCP agents MUST apply at least the following controls:
- Tag text with its principal of origin. Every string the LLM context ingests from the network (tool results, webhook bodies, registry documents, creative metadata) MUST be annotated internally with the
{principal_domain, tool_name, response_field}triple that produced it. Dropping the annotation at ingest time is where this defense dies. - Restrict tool-call targets to the calling principal. A tool call whose target principal is not the same as the principal that supplied the string(s) driving the decision MUST either (a) be refused, (b) go through a human approval step, or (c) be mediated by an explicit per-principal policy the operator has declared up front. The default MUST be refuse, not allow.
- Segregate credential scopes by LLM context. A single LLM planning loop MUST NOT hold live credentials for principals whose interests can conflict (e.g., two brands competing for the same inventory; a buyer credential and a governance agent’s signing key in one context). The scope-segregation is enforced at the process / tool-registration layer, not by instructing the LLM — the LLM MUST NOT have the affordance to misuse.
- Log every cross-principal attempt, not just successes. Refusals under rule 2 are the signal operators MUST monitor — a rising refusal rate from a given principal is the earliest detectable sign of an injection campaign targeting your agent.
Time Semantics
AdCP operates across jurisdictions, ad servers, and daypart calendars. Implementations MUST be precise about time or buyers and sellers will disagree about what “delivered by 5pm” meant.Timestamp format
All timestamp fields in AdCP requests, responses, and webhook payloads MUST be ISO 8601 with an explicit timezone offset.INVALID_REQUEST. Implementations SHOULD use UTC (Z suffix) on the wire and convert to local time at the presentation layer.
Intervals
Any time window in AdCP — flight dates, reporting windows, daypart targeting, idempotency replay TTLs — uses a half-open interval:[start, end). The start timestamp is inclusive; the end timestamp is exclusive. A campaign with start_time: 2026-04-01T00:00:00Z and end_time: 2026-05-01T00:00:00Z runs for April and stops at the first tick of May.
Daypart targeting
Daypart definitions MUST declare their timezone semantics — which of the three meanings the time values carry:- Buyer-declared zone — an IANA zone name alongside the daypart (e.g.,
timezone: "America/New_York"). The daypart is evaluated against that zone regardless of viewer or publisher location. Use this when the buyer wants “9–11pm New York time” enforced globally. - Publisher-local — the daypart is evaluated in the publisher’s declared local zone. Use this when the buyer wants “prime time on the publisher’s schedule” and is willing to let the publisher decide what that means.
- Viewer-local — the daypart is evaluated against each viewer’s timezone, resolved at serve time from the viewer’s location signal. Use this when the buyer wants “serve at 8pm local” across a global audience.
INVALID_REQUEST. Sellers MUST honor the declared semantics; if a seller cannot support the requested mode (e.g., a publisher operating in a single zone cannot serve viewer-local dayparting), the seller MUST reject with INVALID_REQUEST rather than silently converting. Per-agent defaults are non-normative and MUST NOT be relied on.
Request Safety
Idempotency
idempotency_key is required on every AdCP task request — read and mutating alike. Keys are scoped per (authenticated agent, account) — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking create_media_buy under account A and account B is two distinct buys, never one cached response replayed across the two.
Enforcement curve. Sellers MUST reject any mutating request that omits idempotency_key with INVALID_REQUEST from 3.0 onward (unchanged). For read requests, the rule phases in across two minors:
- 3.1.0 — sellers MUST accept reads that carry
idempotency_keyand process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it withINVALID_REQUEST; sellers MAY accept the omission for the 3.1.x maintenance window. - 3.2.0 — sellers MUST reject reads that omit
idempotency_keywithINVALID_REQUEST. The grace window closes at the 3.2 cut.
@adcp/client, adcp-py) already send idempotency_key uniformly today, so SDK-using integrators are unaffected by the cut date.
Why universal — including read tools. Several AdCP tasks are polymorphic. get_products is the canonical case: buying_mode: 'brief' / 'wholesale' may complete synchronously (pure read), but the same tool MAY return a Submitted envelope when curation requires upstream queries or HITL, and buying_mode: 'refine' with action: 'finalize' is a commit that transitions a proposal to committed with an expires_at hold window (see refinement guide § Finalize is exclusive). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the wire contract requires idempotency_key on every call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer’s SDK — is not feasible when the same task name has both read and write modes. Decoding unknown error.code values returned by sellers (whether INVALID_REQUEST during the grace window or codes added in later minors) follows the Forward-compatible decoding rule.
This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (BidRequest.id is a transaction ID, not an idempotency key) and are out of scope.
Normative seller behavior
-
Schema validation runs first. Sellers MUST validate the request against its schema (including presence and format of
idempotency_key) BEFORE consulting the idempotency cache. A malformed request returnsINVALID_REQUESTwithout ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). -
First call is canonical. On task success (
status: completedorstatus: submittedfor async operations), the seller stores the inner response payload (not the protocol envelope) keyed by(authenticated_agent, account_id, idempotency_key)along with a hash of the canonical request payload. The cache entry is immutable — replays within the TTL MUST return the originally-cached payload (withreplayed: true), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource’s current state. This rule applies across both success branches:- Async tasks — the cached response is the
submittedresult containingtask_id. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cachedsubmittedresponse, NOT the current terminal state. The buyer uses the returnedtask_idto observe current state viatasks/getor webhook, exactly as it would have on the first call. - Synchronous-success tasks — when the initial response carries state-tracking fields (e.g.,
status,packages,affected_packagesoncreate_media_buy; per-recordstatusarrays onsync_creatives/sync_accounts; resource snapshots onacquire_rights/activate_signal), replay MUST return the originally-cached payload regardless of intervening mutations to the resource. A media buy that was created withstatus: pending_creatives, then mutated tocanceledviaupdate_media_buy, replays asstatus: pending_creatives— the cached bytes are a historical snapshot of the create-time response, not a current-state read. Buyers MUST consult the resource’s read endpoint (get_media_buys,list_accounts,list_creatives, etc.) for current state; see “Buyer obligations” below.
- Async tasks — the cached response is the
- Only successful responses are cached. On any error — validation, governance denial, transport failure, internal error — the key is not stored. A retry re-executes. This matches buyer intent: a retry after a 5xx should try again, not replay a failure. It also prevents a buyer’s malformed request from being locked into a key for its full TTL.
-
Replay returns the cached response. A subsequent request with the same
idempotency_keyAND an equivalent canonical-form payload (see “Payload equivalence” below) MUST return the stored inner response without re-executing side effects. The seller injectsreplayed: trueonto the outgoing protocol envelope at response time —replayedis an envelope-level field produced by the idempotency layer, NOT part of the cached inner response. Injection at replay time keeps the cached payload byte-stable across replays regardless of envelope changes (newtimestamp, rotatedgovernance_context, etc.). Transport-specific note for MCP: MCP tool responses do not have a separate envelope slot; servers MAY exposereplayedinside the tool result object itself (e.g., at the top of the structured return) or via a response metadata field. REST and A2A responses use the envelope field directly. -
Key reuse with a different canonical payload is a conflict. Same key, different canonical hash within the replay window MUST be rejected with
IDEMPOTENCY_CONFLICT. Sellers MUST NOT silently apply the second request. -
Expired keys are rejected explicitly. After
replay_ttl_secondselapses the seller MAY evict the cache entry. A request arriving after eviction with a key the seller has seen SHOULD be rejected withIDEMPOTENCY_EXPIREDrather than silently treated as new — silent re-execution is exactly the double-booking footgun the key was meant to prevent. Sellers SHOULD allow a ±60s clock-skew window at the TTL boundary (the same tolerance applied to JWSexpelsewhere in this document) so that a retry arriving seconds after nominal expiry is still replayed from cache rather than treated as fresh. Durability is normative. The declaredreplay_ttl_secondsis a durability contract, not a best-effort cache hint. Sellers MUST back the idempotency cache with storage that survives process restarts, pod replacements, region failovers, and operator-initiated cache flushes for the declared TTL. In-memory-only stores (plainMap, single-process LRU without a backing tier) are non-conformant wheneverreplay_ttl_secondsexceeds process lifetime — which is always true at the 3600 s floor. The consequence of silent eviction below declared TTL is a displaced-replay window: the sender legitimately retries with the sameidempotency_keyunder a fresh signature nonce (which is how a signed retry is supposed to work — nonces are per-send, not per-event), passes the signature replay check, and finds the app-layer cache empty because the receiver’s in-memory state was dropped. The side effect runs twice. Sellers MUST NOT declare areplay_ttl_secondshigher than their cache tier can durably honor, and MUST fail-closed (IDEMPOTENCY_EXPIRED) rather than fail-open (silent re-execution) when they cannot distinguish “never seen” from “evicted under declared TTL.” A seller whose operational reality is “memory-only, lost on pod restart” is required to declarereplay_ttl_secondsno higher than the shortest guaranteed pod lifetime — in practice, this forces a durable tier. -
Replay window is declared, not inferred. Sellers MUST declare
capabilities.idempotency.replay_ttl_secondsonget_adcp_capabilities(minimum 3600s / 1h, recommended 86400s / 24h, maximum 604800s / 7d). Clients MUST NOT fall back to an assumed default — a seller with no declaration is non-compliant and MUST be treated as unsafe for retry-sensitive operations. -
Cache-growth defense. Sellers MUST apply per-
(authenticated_agent, account)rate limits on idempotency cache inserts separately from request rate limits, and MUST returnRATE_LIMITED(see error taxonomy) when the per-agent insert rate exceeds the configured ceiling rather than let the cache grow unbounded. A buyer submitting N fresh keys per second on a cheap success-path operation (e.g.,log_event) would otherwise force unbounded storage, with amplification proportional toreplay_ttl_secondsat the 3600 s floor. The natural bound isinserts_per_hour × replay_ttl_hours ≤ max_cache_rows_per_agent. Recommended ceilings (3.1+): the original 60/sec sustained / 300/sec burst single-budget ceiling was sized against a write-heavy launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). Under universal idempotency, read traffic also contributes to insert rate — a single agentic dashboard pollingget_products(brief)+list_creatives+list_accountsacross 5 accounts at 1Hz is ~15 inserts/sec on reads alone, before any write activity. Operators SHOULD adopt a split budget per(authenticated_agent, account):- Reads: 300 inserts/sec sustained, 1,500/sec burst over rolling 10s windows. Dominated by dashboard polling and agentic state re-reads under the Polling / state re-read rule. Read traffic is typically bursty during user-driven UI interactions and steady at low rates during agent runs.
- Writes: 60 inserts/sec sustained, 300/sec burst. Unchanged from the original write-heavy sizing — preserved as a separate budget so a buyer’s dashboard polling can’t exhaust the write capacity that protects
create_media_buy/sync_creatives/activate_signalfrom double-execution races. - Combined cap (defense in depth): total inserts SHOULD NOT exceed 350/sec sustained / 1,700/sec burst per agent — the sum of the two budgets with a small cushion, so an attacker who saturates the read budget cannot starve write capacity.
RATE_LIMITEDfires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the combined-cap rates bound per-agent residency to ~1.26M entries — an order of magnitude above the original 216k from the write-only sizing, reflecting the read-traffic addition; per-agent storage budgeting should account for this. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-RATE_LIMITEDbehavior itself is MUST. Sellers MUST expose the ceilings as tunable configuration parameters — the 300/60 read/write split numbers are first-deployment starting points for an agentic-buyer dashboard pattern, not frozen defaults. Sellers SHOULD NOT publish exact configured ceiling numerics in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceilings through theRATE_LIMITED+retry_afterresponse, not through capability introspection. The ceiling is per(authenticated_agent, account)— the same scope as the idempotency key itself (bullet 1) — so a multi-account agency does not have its per-account budgets collapsed into a single shared quota.RATE_LIMITEDrejections MUST populateretry_after(seconds) per the error handling taxonomy and MUST NOT be cached as idempotency responses (rule 3: only successful responses are cached). Sellers SHOULD enforceretry_afteras a cheap rejection floor — a buyer retrying beforeretry_afterelapses SHOULD hit a pre-auth token bucket (e.g., at a reverse-proxy layer) rather than re-entering the full schema-validate-and-cache-check pipeline on every retry. Without this discipline, misbehaving buyers can amplify load on the rate-limiter itself. -
Concurrent retries — first-insert-wins. A second request carrying the same
(authenticated_agent, account_id, idempotency_key)MAY arrive while the first request is still executing — most commonly when the buyer’s transport timeout fires before the seller’s downstream call returns, and the buyer retries. Sellers MUST resolve the race deterministically; they MUST NOT execute the side effect twice and MUST NOT silently drop the second request. Resolution is a(unique constraint, INSERT … ON CONFLICT DO NOTHING)pattern on the scope tuple: the first row to land owns execution and stores the canonical payload hash on the in-flight row (NOT a sentinel); subsequent requests observe an existing row whose response slot is not yet populated but whose payload hash IS populated. Sellers MUST handle the second request by one of two policies and MUST behave consistently across calls — clients infer the policy from the first response within a session and apply it to subsequent retries:- Wait-and-replay (preferred for fast operations, <5s typical): the seller blocks the second request until the first completes, then returns the cached response with
replayed: true. Total wall-time for the second call is bounded by the seller’s request-timeout budget. - Reject-and-redirect (preferred for slow operations involving long-running downstream calls): the seller returns
IDEMPOTENCY_IN_FLIGHTimmediately, witherror.details.retry_after(seconds, integer) populated based on the first request’s elapsed time and expected completion. Buyers MUST retry with the sameidempotency_keyafter the hint elapses — a buyer that mints a fresh key onIDEMPOTENCY_IN_FLIGHTturns a safe retry into the exact double-execution race this rule prevents.
IDEMPOTENCY_CONFLICT(rule 5), notIDEMPOTENCY_IN_FLIGHT— the canonical-form mismatch is computable at INSERT time against the row’s stored hash, so the conflict is detectable without waiting for the first request’s response. Sellers whose backing store cannot persist the real canonical hash until the handler completes (e.g., a placeholder-sentinel pattern) MUST upgrade the store to persist the hash at INSERT time before declaring rule 9 conformance — the alternative (returningIDEMPOTENCY_IN_FLIGHTon a same-key-different-payload race and only surfacing the conflict after the first request completes) silently delays detection of a real client bug. Per rule 3, if the first request ultimately fails (validation error, downstream timeout, internal error), the(in_flight)row is released — the key returns to “never seen” state and a subsequent retry re-executes from scratch. Sellers MUST bound the lifetime of an in-flight row to their declared per-task handler timeout, and MUST release the row (treat as failed per rule 3) when that timeout fires — even if the downstream has not yet responded. Without this bound, a hung handler indefinitely returnsIDEMPOTENCY_IN_FLIGHTfor the same key, locking the buyer out of any safe retry path. Sellers using reject-and-redirect MUST seterror.details.retry_afterto a value no greater thanreplay_ttl_seconds(declared incapabilities.idempotency). A buyer instructed to wait past the seller’s own replay window is being told to wait until the response can no longer be replayed — the wait is vacuous and the buyer either ends up minting a fresh key (the failure mode this rule prevents) or hitsIDEMPOTENCY_EXPIREDon retry. Sellers SHOULD also declarecapabilities.idempotency.in_flight_max_seconds— the maximum lifetime of an in-flight row, scoped to the seller’s per-task handler timeout. Buyers SHOULD use that declared value as the primary retry-budget bound when present; when absent, fall back to the order-of-magnitude heuristic (a value derived from the seller’s typical handler latency, an order of magnitude below the replay TTL, never the TTL ceiling itself). Sellers MUST NOT leak the in-flight state across the scope boundary: an attacker probing a candidate key MUST receive the same response shape and timing whether the row exists, is in flight, or has never existed. - Wait-and-replay (preferred for fast operations, <5s typical): the seller blocks the second request until the first completes, then returns the cached response with
-
Crossing service boundaries — downstream reconciliation. Sellers commonly invoke downstream systems during request handling — SSP/ad-server calls on
create_media_buy, payment-provider calls on billing operations, governance-agent calls oncheck_governance. These calls have their own failure modes that can leave the seller in a “downstream unknown” state: the network connection dropped after the downstream accepted the request but before its response arrived; the seller process crashed mid-call; a region failover swapped the worker before the response was persisted. Rule 3 (only successful responses cached) is necessary but not sufficient: a seller that simply doesn’t cache and re-executes on retry will double-invoke the downstream and create duplicate side effects there. Conformance grading. This rule is reviewer-graded, not programmatically graded by the compliance storyboard suite. Black-box observation cannot distinguish “the seller has a claim row” from “the seller got lucky on the test run.” Theparallel_dispatch_runnertest-kit lists rule-10 conformance underreviewer_checks— sellers attesting to rule-10 conformance MUST surface their operational runbook describing which pattern applies to which downstream, and reviewers verify the implementation against that runbook. The other normative rules (1–9) are programmatically graded. Sellers MUST adopt one of two reconciliation patterns for every downstream call whose duplicate-invocation has business consequences (resource creation, payment movement, irreversible state change). Read-only downstream calls (cache lookups, eligibility checks that don’t write) are exempt — but borderline cases like fraud-scoring lookups that also write to a downstream audit log count as writes for this rule (the audit log entry is the side effect).- Write-claim-before-invoke (preferred default). Before invoking the downstream, the seller persists a “claim” row in the same transaction as the idempotency cache row — typically
{idempotency_key, downstream_provider, downstream_request_id, status: 'invoked', invoked_at}— using the seller-generateddownstream_request_idit will pass to the downstream as the downstream’s own correlation/idempotency identifier. On retry, before invoking the downstream again, the seller MUST look up the claim row by(idempotency_key, downstream_provider)and reconcile: query the downstream bydownstream_request_idto determine the true outcome, then resume cache population from there. The seller MUST NOT treat a missing local record as “downstream call did not happen” — a crash between downstream-accepts and local-persist is exactly the case where it did happen and the local record is missing. If the downstream reports no record ofdownstream_request_id(the claim row was persisted but the seller crashed before invoking), the seller MUST treat the call as not-yet-invoked and proceed with the invocation; the claim row already reserves thedownstream_request_id, so the downstream’s own idempotency will dedup any subsequent retry. On an ambiguous response from the downstream lookup (transient 5xx, network error, malformed response), the seller MUST fail closed — return a transient error to the buyer (so the buyer retries against the sameidempotency_keyper rule 9) rather than proceed with invocation on an unauthenticated “no record” signal. - Thread-buyer-key (acceptable when the downstream protocol supports it). The seller passes a per-downstream-provider derivative of the buyer’s
idempotency_keyas the downstream’s own idempotency key — typicallyHMAC(K_provider, idempotency_key)whereK_provideris derived from the seller’s KMS-managed root keyed by provider identity (one key per downstream, not one shared seller secret across all downstreams). Per-provider derivation prevents cross-provider replay if any single downstream is compromised; a shared seller secret across all downstreams collapses every provider into a single key-exposure blast radius. The downstream’s at-most-once guarantee then covers the case the seller’s local persistence missed. The seller MUST still write a claim row on the success path so the cached response can be populated correctly, but the downstream itself becomes the source of truth on retry. The seller MUST NOT pass the buyer’s rawidempotency_keyto any downstream operated by a different trust principal — the buyer’s key is a capability token within its TTL (see “Keys are security-sensitive” below) and forwarding it across a trust boundary widens the capability surface. “Different trust principal” means any system the seller does not operate under the same security boundary; passing the raw key to a purely intra-tenant microservice the seller owns end-to-end (same KMS, same audit log, same operator) does NOT cross a trust boundary and is permitted, though per-provider derivation is still the better default.
idempotency_key(or any reversible derivative thereof) in error envelopes returned to the buyer when those errors originated from the downstream. Downstream errors that mention the seller’s per-downstream-provider key (or the buyer’s key, if the seller incorrectly threaded it raw) MUST be re-keyed or stripped before propagating to the buyer — otherwise a downstream error message becomes a cross-trust-boundary key-disclosure surface. The buyer-visible consequence of this rule: when a seller invokes a slow downstream and the buyer retries during the window, the seller’s response on the second request is determined by the seller’s policy under rule 9 (IDEMPOTENCY_IN_FLIGHTor wait-and-replay), not by the downstream’s behavior. Buyers do not need to know which downstream is in the path — the seller MUST present a uniform retry surface regardless. - Write-claim-before-invoke (preferred default). Before invoking the downstream, the seller persists a “claim” row in the same transaction as the idempotency cache row — typically
Payload equivalence
“Equivalent” means identical canonical JSON form, not field-by-field semantic comparison. Sellers MUST determine equivalence by hashing the canonical form and comparing hashes. The canonical form is RFC 8785 JSON Canonicalization Scheme (JCS) — number serialization, key ordering, and escaping all follow JCS §3 normatively. Fields excluded from the hash (closed list — sellers MUST NOT extend it):idempotency_key— the key itselfcontext— buyer-opaque echo data (trace IDs, correlation IDs) changes on retry by designgovernance_context— on the envelope; may be a refreshed signed token on retrypush_notification_config.authentication.credentials— may be a rotated bearer token. The URL and scheme remain in the hash; only the credential value is excluded.
ext — is included, and “missing optional field” is NOT equivalent to “field explicitly set to null” (JCS preserves the distinction, and so does the hash). Buyers MUST NOT place rotating tokens or retry-unstable values inside ext. ext is part of the canonical payload; a value that changes between retries will trigger IDEMPOTENCY_CONFLICT even when the buyer’s intent is unchanged. Rotating credentials belong in the exclusion-list fields above; buyer-side trace data belongs in context. Sellers MUST NOT extend the exclusion list via capabilities, config, or extension — the list is fixed by this spec, and drift there silently weakens retry-safety guarantees across the ecosystem. Any future addition to the exclusion list is a breaking change to payload equivalence (buyers who put a now-excluded value in ext would see previously-distinct retries start deduping against each other), so the list will only grow via a major-version bump with migration notes. New PRs proposing an addition MUST demonstrate why the field is semantically outside the retry contract — not just that a particular buyer happened to rotate it.
Reference implementation: SHA-256(JCS(payload - excluded_fields)).
- TypeScript / JavaScript:
@truestamp/canonifyorcanonicalize - Python:
pyjcsor the reference implementation from RFC 8785 appendix - Go:
gowebpki/jcs - Rust:
serde_jcs
Server-side tool wrapper conformance
Buyer SDKs send envelope-level fields (idempotency_key, context_id, context, governance_context, push_notification_config) uniformly across all AdCP tool calls — buyers cannot know per-tool which envelope fields the seller’s wrapper happens to declare. Servers MUST tolerate envelope-level fields that arrive in tool params but are not declared in the tool’s parameter schema. Concretely:
idempotency_keyis required on every AdCP task request (see rule 1 above — read and mutating alike). Tool wrappers MUST accept it; the idempotency layer routes it per rules 2-9. Wrappers that reject the field withunexpected_keyword_argument(FastMCP/Pydantic strict signatures) are non-conformant.context_id,context,push_notification_config,governance_contextMUST be accepted on every tool, including reads. Tools that don’t consume a given field MUST ignore it; they MUST NOT reject the call because the envelope field is present.
additionalProperties: true default that every published AdCP request schema declares. Configuring a server-side validator in a way that contradicts the schema’s own additionalProperties declaration is a conformance violation. Common server-implementation traps:
- FastMCP / Pydantic with strict signatures — a tool wrapper declared as
def get_products(brief: str)raisesunexpected_keyword_argumentwhen the buyer sendsidempotency_keyinside the same params object. Fix: declareidempotency_key: str | None = None(and the other envelope fields) as accept-and-ignore optional parameters, or use a**kwargscatch-all and discard unknown keys. Pydantic-on-input usesExtra.allowormodel_config = ConfigDict(extra='allow'). - Zod / valibot with
.strict()on the inbound request schema rejects unknown keys for the same reason; remove.strict()on input schemas, or compose with a passthrough variant. - OpenAPI-generated server stubs with
additionalProperties: falseinjected by the codegen tool — verify the generated input schema mirrors the spec’sadditionalProperties: truedefault; some generators flip the default during model emission.
runner-output-contract.yaml > response_schema_validator_semantics — both rules express the same principle (“validator configuration MUST NOT contradict the schema’s own additionalProperties declaration”) on the two ends of the wire.
Response-level replay indicator
The protocol envelope carries a top-levelreplayed boolean on responses to any request that resolved via the idempotency cache:
replayed is produced by the seller’s idempotency layer at response time, not stored in the cache. On a fresh execution it is false (or omitted — buyers MUST treat omission as false). On a cached replay it is true; the inner payload is byte-for-byte what was stored on the original successful execution. Envelope fields (timestamp, context_id, etc.) may differ — they describe the current response, not the cached one.
Buyers use replayed for:
- Agent side-effect suppression — an agent that acts on response data before a human sees it (notifications, downstream tool calls, memory writes) MUST check
replayedto avoid re-emitting on retry. “Campaign created!” notifications, LLM memory inserts, and downstream agent calls are exactly what silent replay breaks. - Side-effect invariants — downstream systems expecting exactly-once event semantics read
replayedbefore treating the response as a new event. - Billing reconciliation — “we processed N buys this month” counts
replayed: falseonly. - Logging — distinguishing “retry succeeded by returning cache” from “retry triggered a new execution” (the latter usually signals a bug in the replay window or key management).
- State-machine routing — state-tracking fields in the cached
payload(e.g.,status: pending_creativeson a replayedcreate_media_buy) are a historical snapshot, not a current-state read (see seller rule 2 and “Replay responses are historical snapshots” under buyer obligations). Buyers MUST re-read via the resource’s read endpoint before any state-dependent action.
IDEMPOTENCY_CONFLICT response shape
Standard AdCP error envelope. The error body:- MUST include
code: "IDEMPOTENCY_CONFLICT"and a human-readablemessage - MUST NOT include the cached response, the original payload, a canonical-form diff, or any fingerprint derived from them. A
fieldjson-pointer hint seems harmless but reveals schema shape (e.g.,/packages/0/budgettells an attacker the victim’s payload had a budget in the first package). Sellers MUST NOT emit one. A legitimate buyer debugging a retry can diff their own two payloads — they have both.
SI send_message idempotency model
si_send_message needs a narrower scope than other mutations because conversational turns advance session state. The key is scoped (authenticated_agent, account_id, session_id, idempotency_key).
- Retry of turn N within the TTL returns the cached response for turn N, even if turn N+1 has since been accepted. Idempotency returns what you did, not rewinds what the session is. The buyer’s retry is asking “did my message get through” — the answer is still “yes, here’s what came back.”
- A new
si_send_messagewith a freshidempotency_keyis a new turn, processed against the current session state. Buyers MUST generate a fresh key per logical turn, not per HTTP attempt. - If the seller has advanced session state past turn N and cannot reproduce the cached response byte-for-byte (e.g., the session was pruned for storage), the seller MAY return
SESSION_NOT_FOUNDorIDEMPOTENCY_EXPIREDrather than reconstruct. Buyers retrying far past a session timeout should expect this.
Buyer obligations
Buyers MUST generate a uniqueidempotency_key per (seller, request) pair. Reusing the same key across sellers allows colluding sellers to correlate requests from the same buyer. Use a fresh UUID v4 for each request. On retry after a network error, buyers MUST resend the exact same payload with the same key — changing either side breaks at-most-once semantics. In particular, buyers MUST NOT change push_notification_config.url between retries with the same key; URL is part of the canonical hash and rotating it triggers IDEMPOTENCY_CONFLICT. Rotate the key when changing webhook configuration.
Network retry vs. agent re-plan vs. polling / state re-read. Three cases that look similar but need different handling:
- Network retry — socket timeout, 5xx, transient failure. The buyer has the same intent and sent the same bytes — and MUST resend them with the same key. This is what idempotency_key exists for.
- Agent re-plan — the buyer is an agent whose planner re-ran (prompt re-executed, tool output changed, policy re-evaluated) and produced a different payload. The intent has changed. The agent MUST mint a new key and treat the prior request as abandoned. Reusing the prior key with a different canonical payload returns
IDEMPOTENCY_CONFLICT, which is the seller correctly telling the agent “you’re not retrying, you’re doing something new.” - Polling / state re-read — a dashboard polling
get_products(brief),list_creatives,list_accountsat intervals; a buyer agent readingget_media_buysto fetch fresh state after a mutation; any “give me current state at time T” call. Buyers MUST mint a freshidempotency_keyper call. Reusing the prior poll’s key would replay the cached snapshot (up toreplay_ttl_seconds), silently returning stale data — exactly the failure mode the cache exists to prevent on mutations. This rule also governs the re-read step in the Replay responses are historical snapshots pattern below: the “re-read for current state” call MUST carry a fresh key, never the key from the mutation it’s reading state for.
get_adcp_capabilities. The discovery call itself is exempt from rules 1–9 of this section. get_adcp_capabilities is how the buyer learns whether the seller declares adcp.idempotency.replay_ttl_seconds, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit idempotency_key on get_adcp_capabilities, and sellers MUST accept the call without it. Buyers that send idempotency_key on get_adcp_capabilities (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every other AdCP task request remains subject to rules 1–9; the fail-closed obligation below applies once the capability fetch has completed.
When the seller’s capability declaration is missing. A seller whose get_adcp_capabilities response omits adcp.idempotency.replay_ttl_seconds is non-compliant. After a successful capability fetch, client SDKs MUST fail closed on every subsequent AdCP task request against that seller — raise an error, don’t assume a default — so the buyer learns about the non-compliance immediately rather than after a silent double-booking. The fail-closed rule applies to every AdCP task request (other than get_adcp_capabilities itself) now that idempotency_key is required universally — including calls that resolve as pure reads, because the buyer cannot predict at call time whether a polymorphic task (get_products brief vs. refine+finalize vs. async-Submitted) will resolve as a read or a mutation, and the missing TTL declaration means the seller is unsafe to retry against in any mode.
Decoding seller-emitted error codes. Sellers MAY return error codes (IDEMPOTENCY_CONFLICT, IDEMPOTENCY_EXPIRED, IDEMPOTENCY_IN_FLIGHT, INVALID_REQUEST, or codes added in later minor versions) that buyers’ pinned vocabulary may not recognize. Receivers MUST decode these per Forward-compatible decoding (normative) — read error.recovery for the recovery classification, default to transient when recovery is absent, and never reject the response because the code value is unfamiliar. The retry semantics for transient-classified errors are bounded by § Retry Logic (maxRetries and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a transient default.
Replay responses are historical snapshots. A response carrying replayed: true is byte-equivalent to the original first-call response (per seller rule 2) — state-tracking fields in it reflect the resource’s state at first-call time, NOT the resource’s current state. A buyer that reads status: pending_creatives from a replayed create_media_buy response and then calls update_media_buy(canceled: true) on a resource that has actually been in canceled for hours will surface a NOT_CANCELLABLE error and a state-machine bug. Buyers requiring current state MUST consult the resource’s read endpoint — get_media_buys for media buys, list_accounts for accounts, list_creatives for creatives, get_signals for signals, equivalents for other resources. replayed: true is the explicit signal that a fresh read is required before any state-dependent decision; SDKs SHOULD surface the flag to caller code rather than transparently unwrap it. Agentic buyers MUST treat replayed: true as a stop signal for any planning step whose next action depends on resource state, and MUST re-read before continuing.
The re-read MUST carry a fresh idempotency_key. Reusing the key from the mutation whose state you’re re-reading either returns IDEMPOTENCY_CONFLICT (if the read payload differs from the mutation payload — almost always true) or, worse, returns the cached mutation response itself (if the payloads happen to match). Reusing a prior read’s key returns that prior read’s cached snapshot — the exact stale-state failure mode this rule exists to prevent. State re-reads fall under the Polling / state re-read case above; mint a new key per call.
TTL boundary for persisted keys. Some buyers persist idempotency_key alongside their own object (e.g., campaign.pending_idempotency_key in the buyer’s DB) so that retries after a process restart or overnight reconcile still dedup. This works only within the seller’s declared replay_ttl_seconds. Beyond the TTL, the seller will either reject the retry with IDEMPOTENCY_EXPIRED (good) or, if the cache was evicted, treat it as a new request (silent double-booking — the failure mode this field exists to prevent). Buyers retrying past the TTL MUST fall back to a natural-key check (e.g., query get_media_buys by context.internal_campaign_id) before resending. The idempotency_key guarantees at-most-once execution within the replay window, not forever. Queue-based retry systems and workflow engines with retry horizons longer than the seller’s TTL MUST be designed around this — don’t put a key into a dead-letter queue that replays days later without a natural-key re-check.
Keys are security-sensitive. An idempotency_key is a secret capability token within its TTL — anyone who holds one and knows the original payload can replay it and read the cached response. Treat keys the way you treat session tokens: do not log them in full, do not embed them in URLs, do not share them across agents. Log prefix-only (first 8 chars of the UUID) if you need correlation. Buyers persisting pending_idempotency_key at rest (e.g., alongside a campaign row in the buyer’s DB) MUST encrypt it with the same controls used for bearer tokens, and SHOULD purge the key after success confirmation to minimize the exposure window.
Sellers MUST encrypt the cache tier at rest. Under universal idempotency (3.1+), the cache holds read-tool responses (get_products, list_accounts, list_creatives, get_signals, etc.) in addition to the write receipts it held in 3.0.x. Those read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller’s underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by (authenticated_agent, account_id) at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant’s cached read response.
Keys MUST be unguessable. Schema enforces ^[A-Za-z0-9_.:-]{16,255}$ and buyers MUST use UUID v4 (~122 bits of entropy) or an equivalent CSPRNG-generated value. Low-entropy keys like retry-001 or monotonic counters turn the cache into an enumerable surface: an attacker can walk the key space and test each one against a target agent. Sellers SHOULD reject keys that fail a basic entropy check (e.g., all-zeros, repeated characters, short ASCII words) with INVALID_REQUEST when the authenticated agent is not individually trusted.
The three-state response (success / IDEMPOTENCY_CONFLICT / IDEMPOTENCY_EXPIRED) is an existence oracle for idempotency keys. An attacker who holds a candidate key can probe it: success means never seen, IDEMPOTENCY_CONFLICT means live with a different payload, IDEMPOTENCY_EXPIRED means previously used. The per-(agent, account) scoping above is the primary defense — an attacker authenticated as agent A cannot probe agent B’s keys, and a caller scoped to account A cannot probe account B’s keys even under a shared agent credential. Unguessable keys are the secondary defense — an attacker who cannot guess a victim’s key cannot probe the oracle usefully. Sellers MUST NOT surface IDEMPOTENCY_EXPIRED across scope boundaries or to unauthenticated callers. Sellers SHOULD also avoid distinguishable timing between “key exists” and “key does not exist” lookups in the idempotency layer; a constant-time floor on the negative path closes a side channel that persists even without an error-code oracle.
SI session scope. For si_send_message the key is scoped (authenticated_agent, account_id, session_id, idempotency_key). session_id is therefore part of the oracle surface: if session IDs are guessable, an attacker who steals one key can probe it against many sessions. SI sellers MUST generate session_id server-side using a CSPRNG with ≥122 bits of entropy (UUID v4 or equivalent) and MUST NOT derive it from anything observable to another agent (request sequence number, user handle, timestamps). The same idempotency_key sent with a different session_id is a different scope tuple — always a new request, never a conflict.
account_id entropy for cache-scope safety. account_id is part of every idempotency scope tuple, so it is also part of the oracle surface: an attacker authenticated as agent A with a stolen idempotency key could probe it against candidate account IDs to enumerate accounts in A’s authorized set or learn which accounts A has ever operated on. When account IDs are short sequential or semantic values (acct_123, nike-us), this is a real enumeration channel. Sellers that issue server-assigned account IDs MUST use unguessable values (UUID v4 / ULID, ≥122 bits of entropy) for any account ID that participates in an idempotency cache scope. Sellers operating under the buyer-declared account model (natural-key {brand, operator}) MUST hash the natural key with a seller-local salt before using it as a cache-scope component — the natural key is public by design and cannot be used directly as an oracle defense.
Natural-key idempotency is not a substitute
Upsert-style tasks (sync_accounts, sync_audiences, sync_catalogs, sync_event_sources, sync_governance, sync_plans) already dedup at the resource level — two calls with the same account_id or audience_id produce one row, not two. That’s resource idempotency.
idempotency_key guarantees something stricter: envelope idempotency. The entire request — including its side effects — executes at most once. Retrying the same sync envelope without a key can still fire onboarding webhooks twice, emit duplicate audit log entries, or double-provision pixel endpoints, even though the resource rows end up identical. The key is what makes a retry truly safe.
The one exception in the spec is si_terminate_session: session_id plus the “terminate” verb is fully idempotent — a second call on an already-terminated session returns the same terminal state with no new side effects — so that schema doesn’t require idempotency_key.
Signed Governance Context
governance_context crosses trust boundaries — from governance agent to buyer to seller and back, and ultimately to auditors and regulators who may need to verify an approval long after the original transaction closed. AdCP 3.0 tightens the value format to a compact JWS signed by the governance agent so any party can verify authenticity, binding, and replay without subpoenaing the issuer.
Roles:
- Governance agents sign the token. They are the only party that signs.
- Buyers attach the token they received from their governance agent to the protocol envelope and forward to the seller. Buyers MUST NOT construct, modify, or re-sign the token. Buyers SHOULD retain the
jtiandcheck_idfor their own audit record. - Sellers persist the token as received and include it verbatim on all subsequent governance calls. Sellers that implement verification MUST verify per the checklist below before acting on the token. Sellers that have not yet implemented verification MUST still persist and forward the token unchanged so that verification-capable parties downstream (auditors, regulators) can act on it later.
- Auditors and regulators verify independently using the governance agent’s published keys — this is the accountability property the signed format exists to deliver.
Scope and dependencies
- In scope (3.0): buy-side governance. The
governance_contexttoken authorizes spend commitments made via AdCP tasks (create_media_buy,acquire_rights,activate_signal,creative_services). Sellers that run their own compliance policies (e.g., CTV political-ad rules, publisher brand-safety gates) express those viaconditionsresponses on their own governance workflows; they do not issue signed tokens under this profile. - Out of scope (3.0): seller-side governance authorities. A future RFC may extend this profile to cover seller-side signed decisions declared via
adagents.json. - Out of scope (ever): OpenRTB bid streams. Governance attestation terminates at the AdCP media buy boundary. Threading a signed attestation through per-impression bid requests is operationally infeasible (one token, many recipients, broadcast-fan-out) and unnecessary (spend authorization happens at media buy time, not per-impression).
iss claim — see Buyer identity resolution below. In 3.0 without #2307, sellers MUST either use mTLS or a pre-provisioned buyer API key to establish buyer identity; treating the request’s bearer token alone as identity input to brand.json resolution is circular and does not prevent spoofing. 3.1 normatively requires #2307-style signed requests.
AdCP JWS profile
This profile applies togovernance_context (#2306) and to any future AdCP artifact that is signed as a standalone token. Transport-layer request signing (#2307) uses RFC 9421 HTTP Signatures but shares the JWKS discovery described here. Governance signing keys MUST NOT also be used as #2307 transport-signing keys — the JWKS endpoint is shared, but each key entry MUST declare "key_ops": ["verify"] and "use": "sig" and occupy a distinct kid. Verifiers MUST enforce key-ops separation to prevent cross-purpose key reuse.
Header
alg:EdDSA(Ed25519) RECOMMENDED on server-side runtimes.ES256(ECDSA P-256) RECOMMENDED on edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) where Ed25519 may require explicit runtime configuration. Verifiers MUST rejectnone,HS*, and anyRS*variant below 2048-bit. Verifiers MUST enforce the allowlist on the token header; they MUST NOT rely solely on library defaults.kid: REQUIRED. Identifies the signing key in the issuer’s JWKS.typ: REQUIRED. MUST be exactlyadcp-gov+jws(byte-for-byte match; verifiers MUST NOT normalize or strip the+jwsstructured suffix per RFC 6838 §4.2.8). The typed header prevents a governance signing key from being tricked into validating a generic JWT for another purpose.crit: REQUIRED if anycrit-listed claim is present. Per RFC 7515 §4.1.11,critis an array of header/claim names that MUST be understood by the verifier. Verifiers MUST reject the token if any name incritis not recognized. Governance agents MUST list incritany claim whose omission or misinterpretation would change authorization semantics (e.g., a futurebudget_capclaim). This prevents silent downgrade attacks when the profile adds claims in later versions.
| Claim | Required | Description |
|---|---|---|
iss | Yes | Governance agent identifier. MUST be an HTTPS URL that byte-for-byte matches the url of a governance-typed entry in the buyer’s brand.json, including any path component. Path-level matching is required so multi-tenant SaaS governance agents (e.g., https://gov.vendor.com/tenant/acme) cannot be spoofed by sibling tenants sharing the same origin. |
sub | Yes | plan_id the token authorizes. Note: sub is used here as a resource identifier rather than a user or authenticated agent. Implementations that log sub as a user ID should be aware of this. |
plan_hash | Yes | Audit-layer binding of the attestation to the evaluated plan state. Not part of the seller verification checklist — sellers treat it as opaque cargo. Semantics, canonicalization, and verification paths are defined in Plan binding and audit. |
aud | Yes | Target seller identifier. MUST be the exact URL string from the seller’s adagents.json entry that authorized this seller for the property being purchased, byte-for-byte including scheme, host, port, and path. Case-sensitive; no path-prefix match. For intent tokens where the buyer is evaluating multiple sellers, the buyer MUST request one token per target seller (see Intent-phase disclosure for the privacy trade-off). |
iat | Yes | Issued-at timestamp (seconds since epoch). |
nbf | No | Not-before timestamp. When present, verifiers MUST reject if now < nbf (with ±60 s skew). |
exp | Yes | Expiration timestamp. Intent tokens SHOULD expire within 15 minutes. Execution-phase tokens (purchase, modification, delivery) MUST expire within 30 days; governance agents refresh longer lifecycles by issuing a new token on each lifecycle check. |
jti | Yes | Unique token identifier. Used by sellers for replay detection and by auditors for correlation. RECOMMENDED format: UUID v7 or ULID for time-orderability. |
phase | Yes | intent (pre-seller), purchase, modification, or delivery. Matches the governance check phase this token authorizes. The operation the seller is performing determines the required phase: create_media_buy → purchase; update_media_buy → modification; delivery-reporting callbacks → delivery. |
caller | Yes | URL of the party that requested the governance check that produced this token. In intent phase, this is the orchestrator/buyer; in execution phases, this is typically the seller itself (as callbacks arrive with the seller as caller). |
check_id | Yes | Governance agent’s check_id for this decision; correlates to report_plan_outcome and get_plan_audit_logs. |
media_buy_id | Conditional | Seller-assigned media buy ID. MUST be present on purchase, modification, and delivery phase tokens. MUST be null or absent on intent phase tokens. |
policy_decisions | No | Compact array of { policy_id, outcome } entries (may include confidence). Visible to the seller. Governance agents SHOULD omit this in privacy-sensitive deployments (see Privacy considerations) and use policy_decision_hash instead. |
policy_decision_hash | No | SHA-256 hash of the canonicalized decision log, hex-encoded. When present, sellers treat it as an opaque integrity anchor; full log is retrievable by auditors via audit_log_pointer. Governance agents MUST include either policy_decisions or policy_decision_hash (both is permitted). |
audit_log_pointer | No | HTTPS URL consumable by get_plan_audit_logs for the full decision evidence. When present, auditors can fetch the full log using the pointer; access control is governed by the governance agent. |
status | No | Optional forward-compatibility hook. When present, MUST be a JSON object conforming to a future IETF JWT Status List mechanism (draft-ietf-oauth-status-list). Verifiers that do not understand status MUST NOT reject solely on its presence unless it appears in crit. |
crit header, in which case the token MUST be rejected. This asymmetric rule — ignore unknown, but reject unknown-and-critical — is how future versions of the profile add semantically meaningful claims without breaking backward compatibility for verifiers that haven’t updated yet.
Size: a typical token with policy_decision_hash fits comfortably under the 4096-character envelope limit. Implementations MUST NOT put large evidence payloads in the token; use audit_log_pointer instead.
plan_hash is audit-layer, not wire-layer: the plan_hash claim is cryptographic cargo the token carries for off-wire verification by the governance agent, auditors, and buyer-side compliance. It is not part of this profile’s seller verification contract and is never listed in crit. Canonicalization, excluded fields, retention rules, and test vectors are specified in Plan binding and audit (governance spec). Sellers persist and forward governance_context verbatim and perform the 15-step verification checklist below — authenticity, authorization scope, freshness — without inspecting plan_hash.
Buyer identity resolution
The brand.json cross-check (step 13 of the verification checklist) is the anti-spoofing control. It requires sellers to know which buyer’s brand.json to consult — the authenticated agent proves who is calling, and the resolution chain maps that agent to the buyer domain whose brand.json the seller should fetch. In 3.0 sellers MUST establish the buyer domain via one of:- mTLS: buyer presents a client certificate; the certificate Subject/SAN resolves to the buyer’s registered domain; the seller fetches
https://{domain}/.well-known/brand.json. - Pre-provisioned buyer identity: an API key or OAuth client identifier issued by the seller at onboarding, mapped to the buyer’s domain in the seller’s records.
- Signed requests per #2307 (3.1 normative): RFC 9421 HTTP Signatures with
keyidresolving to a buyer-declared public key in the buyer’s adagents-style agent registry.
iss, caller, or any client-supplied header). Doing so creates a circular trust chain: the attacker proves “I am the buyer” by presenting a token signed by an attacker-controlled governance agent declared in an attacker-controlled brand.json. In particular, the token’s iss is untrusted input until step 13 of the verification checklist confirms it appears as a governance-typed entry in the authenticated buyer’s brand.json — the authentication mechanism (mTLS, API key, or signed request) establishes the buyer domain first, and only the brand.json fetched from that domain is trusted to attest which governance agent (iss) may sign for this buyer.
brand.json resolution follows one redirect (authoritative_location or house redirect variant) and stops. Sellers MUST NOT follow redirect chains.
Key discovery (JWKS)
Sellers and auditors resolve the governance agent’s public keys via JWKS (RFC 7517):- Establish the buyer domain via the rules in Buyer identity resolution.
- Fetch the buyer’s brand.json. Locate the
agents[]entry whosetypeisgovernanceand whoseurlbyte-for-byte equals the token’siss. Reject if no matching entry exists. - Use the entry’s
jwks_uriif declared. If absent, default to{origin of iss}/.well-known/jwks.jsonwhere origin = scheme+host+port per RFC 6454. Multi-tenant governance agents serving multiple buyers from a shared origin MUST declare explicit per-tenantjwks_uriso tenant key material is not pooled across the origin. - Fetch the JWKS over HTTPS.
- Locate the key in the JWKS whose
kidmatches the token header. On cache miss for akid, refetch the JWKS once (respecting a minimum 30-second cooldown to prevent unbounded refetches) before rejecting.
kid is added to revoked_kids but the seller’s JWKS cache still serves the revoked key for validation, only the revocation check (performed independently per step 14) catches the fraud.
SSRF protection: jwks_uri and the revocation-list URL are counterparty-supplied. All outbound fetches to these URLs MUST follow the SSRF controls defined in Webhook URL validation: reject non-HTTPS, reject resolved IPs in reserved ranges (including cloud metadata addresses), pin the connection to the validated IP, refuse redirects, cap response size and timeouts, suppress detailed error messages to the counterparty. A JWS profile without SSRF discipline on key discovery is a metadata-exfiltration vector.
Seller verification checklist
Before treating a request as governance-approved, sellers MUST perform these checks in order, short-circuiting on the first failure:- Parse the compact JWS. Reject if malformed.
- Reject if header
algisnoneor not in the allowed list (EdDSA, ES256). Library defaults MUST NOT be relied upon. - Reject if header
typis not exactlyadcp-gov+jws(no normalization). - Reject if the header contains a
critarray and any listed name is not recognized by the verifier. - Resolve
issto a JWKS via the discovery rules above. Reject if the JWKS cannot be fetched (after SSRF validation) or thekidis not present after one refetch. - Verify the JWKS entry’s
useis"sig"andkey_opsincludes"verify". Reject keys marked for other uses. - Cryptographically verify the signature.
- Reject if
auddoes not byte-for-byte equal the seller’s own canonical URL as declared in the relevantadagents.jsonentry. - Reject if
expis in the past oriatis more than 60 seconds in the future (±60 s clock-skew tolerance, symmetric on both bounds). Ifnbfis present, reject ifnow < nbf − 60 s. - Reject if
subdoes not equal theplan_idin the governance call this token is attached to (prevents plan swap). - Reject if
phasedoes not match the operation:purchaseforcreate_media_buy;modificationforupdate_media_buy;deliveryfor delivery-reporting callbacks;intentonly for pre-seller buyer-side evaluation. - For non-intent tokens, reject if
media_buy_iddoes not equal the media buy ID in the request. - Cross-check: the token’s
issMUST appear as a governance-typed agent in the buyer’s current brand.json (established via Buyer identity resolution). Sellers SHOULD cache brand.json with reasonable TTLs (recommend 1 hour) and refresh on verification failure. - Check the revocation list (see Revocation). Reject if
jti∈revoked_jtisor if the token header’skid∈revoked_kids. This check runs on every verification, not only on cache miss. - Reject if
jtihas been seen before for this(iss, aud)tuple. See Replay dedup for storage guidance.
plan_hash — that claim is bound at the governance-agent / auditor layer (see Plan-state binding).
Replay dedup
Step 15 requires trackingjti values to prevent replay. The naive implementation — an unbounded set — is both a memory risk and a DoS vector (attacker floods the seller with unique tokens to exhaust storage).
Scaling recommendations:
- Cap execution-token
expat 30 days (enforced by governance agents; sellers reject anything longer). This bounds the dedup window. - Use a bloom filter keyed on
(iss, aud, jti)with a small false-positive rate (~1 in 10⁶) as the fast-path check, with authoritative lookup in a bounded store (RedisSET jti NX EX <remaining_ttl>, Postgres unique index with TTL cleanup) only on bloom-filter hits. - Governance agents SHOULD issue
jtivalues in a time-orderable format (UUID v7 or ULID) so sellers can partition the dedup store by time window and drop expired partitions cheaply.
Revocation
Exp-based expiry alone does not cover execution-phase tokens that live for a media buy’s lifecycle. Governance agents MUST publish a revocation list at{origin of iss}/.well-known/governance-revocations.json and MUST sign the list itself using a key in the same JWKS:
revoked_jtisinvalidates individual decisions (e.g., a plan was rescinded). Revocation applies to any token with thatjti, regardless of signing key.revoked_kidsinvalidates every token ever signed under thatkid(before or after the revocation timestamp), not just tokens issued after.issuerMUST match theissorigin of tokens this list governs. Prevents cache substitution across issuers by a shared CDN.- The list is signed so a compromised CDN or DNS origin cannot serve a stale or tampered list to un-revoke a compromised key.
- Sellers MUST poll the list on the cadence declared in
next_update. - Floor: 1 minute. Ceiling: 30 minutes for any seller accepting execution-phase tokens. Governance agents MUST NOT declare
next_updatemore than 30 minutes in the future for issuers covered by execution-phase traffic. Thenext_updatevalue is a JSON timestamp, not an HTTP cache header — standard HTTP caches will not respect it; sellers MUST parse and honor it themselves. Sellers that prioritize fast key-compromise propagation over DoS tolerance SHOULD poll at or near the floor; the ceiling exists for sellers that accept slowerrevoked_kidspropagation in exchange for tolerating longer revocation-endpoint outages. - Polling is optional for intent-phase tokens with ≤15 min
exp(the intent-tokenexpcap from the JWT claims table above — distinct from the polling ceiling, even though the numbers were previously coincident). - Use HTTP conditional requests (
If-Modified-Since/ETag) to avoid unnecessary body transfers.
next_update + grace (recommend grace = 4× the previous polling interval), the seller MUST reject any new purchase, modification, or delivery phase token until the list is refreshed. This prevents an attacker who DoSes the revocation endpoint from extending the fraud window of a compromised key. Sellers operating at the polling ceiling get ~2.5 h of endpoint-outage tolerance; sellers at the floor get ~5 min. Tune the polling cadence — not the grace constant — to your risk appetite.
- Governance agents MUST retain revoked public keys as discoverable for the audit retention period (recommend 7 years) so auditors can verify historical tokens after the current rotation. Revoked keys SHOULD be served at
{origin}/.well-known/jwks-archive.json(separate from the active JWKS).
Key rotation
- Governance agents rotate by adding a new key to JWKS with a new
kid, signing fresh tokens with the newkid, and leaving the old key published until the longest-lived outstanding token expires. - Seller JWKS caches MUST invalidate and refetch on a missing-
kidfailure before rejecting (with a 30-second cooldown to prevent unbounded refetches). - Emergency rotation (key compromise) proceeds by adding the old
kidto the signedrevoked_kidslist and rotating to a new key immediately. Short exp on intent tokens, capped exp on execution tokens, and revocation-list polling together bound the fraud window.
Verification error taxonomy
Sellers and client libraries SHOULD surface verification failures with these codes so that retry vs reject semantics are consistent across the ecosystem. AdCP client libraries (@adcp/sdk and equivalents) SHOULD expose typed errors that map to this taxonomy.
| Failure | Retry? | Code | Notes |
|---|---|---|---|
| JWKS fetch timeout or 5xx | Yes, with backoff | governance_jwks_unavailable | Transient. Retry with exponential backoff; abort after N attempts. |
| JWKS fetch fails SSRF validation | No | governance_jwks_untrusted | Permanent. Indicates misconfigured jwks_uri or an attack. |
kid not in JWKS after refetch | No | governance_key_unknown | Reject. Possibly indicates rotation lag or key revocation. |
Signature invalid, typ mismatch, alg not allowed, crit unknown | No | governance_token_invalid | Reject. Indicates tampering or implementation bug. |
exp in past, jti replayed, nbf in future | No | governance_token_expired / _replayed / _not_yet_valid | Reject. Tokens cannot be healed by retry. |
jti ∈ revoked_jtis or kid ∈ revoked_kids | No | governance_token_revoked | Reject. |
iss not in buyer brand.json | No | governance_issuer_not_authorized | Reject. Possibly indicates a spoofing attempt. |
| Revocation list not refreshed within grace | No (block new) | governance_revocation_stale | Reject new tokens until revocation list refreshes. Existing fully-verified tokens may continue to be trusted within their existing grace. |
aud mismatch, sub mismatch, phase mismatch, media_buy_id mismatch | No | governance_token_not_applicable | Reject. Token valid but not for this operation. |
Privacy considerations
policy_decisions visibility: the token is a JWS (readable by anyone with the public key), not a JWE (encrypted). If policy_decisions contains the full list of policy IDs the governance agent evaluated, every seller who receives the token learns which policies the buyer’s governance posture considers — competitive intelligence, and in some cases signaling about sensitive audience characteristics (e.g., a minors_compliance policy ID implies targeting of under-18 audiences). Governance agents SHOULD use policy_decision_hash in place of policy_decisions when the buyer’s compliance posture is sensitive; the full log remains available to auditors via audit_log_pointer with governance-agent-controlled access.
Intent-phase seller disclosure to GA: the aud binding means a buyer evaluating N sellers in a competitive auction must request N distinct intent tokens, each aud-bound to one seller. The governance agent therefore sees the full list of sellers the buyer considered — a privacy regression relative to the opaque-string model where sellers were unknown to the GA at intent time. This is an explicit trade-off: cross-seller replay resistance requires per-seller binding. A future aud_hash mechanism (where the token binds a hash of the seller URL with a token-scoped salt, and each seller computes the hash on its own URL to verify) can recover intent-time seller privacy against the GA without sacrificing replay resistance. Not defined in 3.0; tracked as a follow-up.
caller URL: contains the orchestrator’s identifier. Sellers and auditors who retain tokens long-term should be aware of the retention policy implied by this.
Reference implementation
Decoded example token (intent phase): Header:jose):
Migration (3.0 → 3.1)
- 3.0: governance agents MUST emit compact JWS per this profile, including the required
plan_hashaudit-layer claim (see Plan binding and audit for semantics). Sellers MAY verify the 15-step checklist; sellers that do not verify MUST persist and forward the token unchanged. Values that are not JWS are deprecated and SHOULD only appear from pre-3.0 governance agents during the transition; governance agents that emit non-JWS values in 3.0 MUST declare this in their capabilities so sellers can detect unverifiable deployments. - 3.1: all sellers MUST verify per the 15-step checklist. Governance agents MUST emit JWS. Non-JWS values will be rejected end-to-end.
plan_hashremains audit-layer (governance-agent / auditor / buyer-compliance verification only — not seller verification).
Signed Requests (Transport Layer)
Signed Governance Context signs an authorization artifact. Request signing signs the request itself — method, target URI, headers, and (by default) body bytes — establishing cryptographically that a specific agent issued the request, with replay and tampering protection. A valid signature proves only one thing: the request came from the agent whose key signed it. Whether that agent is authorized to act for the brand named in the request body is a separate concern, governed by the target house’sauthorized_operator[] in brand.json. This section defines authentication only; authorization lookup is specified by the brand.json schema and happens whether requests are signed or not.
AdCP 3.0 defines this profile as optional and capability-advertised via request_signing on get_adcp_capabilities. AdCP 4.0 — the next breaking-changes accumulation window — will require it for spend-committing operations. The substrate ships in 3.0 so early adopters can surface canonicalization and proxy interop bugs before enforcement. See Transport migration timeline.
Roles:
- Agents sign requests with a key published at their own
jwks_uriin their operator’s brand.jsonagents[]entry. The operator (the domain hosting brand.json) may be a house buying direct or an authorized third party — this profile does not distinguish. The signer is always an agent. - Sellers verify the signature against the signing agent’s published key, establishing agent identity. Sellers then perform the separate brand-operator authorization check (outside this profile’s scope).
- Sellers calling agent-side AdCP endpoints (e.g., buyer-hosted mutation callbacks that are themselves AdCP protocol calls) sign their outgoing requests symmetrically; the receiving agent verifies against the seller’s keys published under the seller operator’s brand.json
agents[]entry. Push-notification webhook callbacks (push_notification_config.urland similar asynchronous one-way notifications) are covered by the symmetric Webhook callbacks variant of this profile — the seller signs outbound with anadcp_use: "webhook-signing"key and the buyer verifies.
- Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the AdCP JWS profile above. Cross-purpose key reuse is forbidden: a request-signing JWK MUST declare
"adcp_use": "request-signing","use": "sig","key_ops": ["verify"], and akidthat does not appear on any other JWKS entry with a differentadcp_use. Verifiers enforce all four; see Agent key publication. - Resolves the identity-bootstrapping dependency in Buyer identity resolution for governance: a seller that verifies a request signature has a cryptographically established signing agent identity and MAY use the signing agent’s operator domain as the brand.json resolution input for the governance verification step.
/compliance/latest/universal/signed-requests, which runs for any agent advertising request_signing.supported: true. The storyboard exercises every step in the verifier checklist below and every canonicalization-edge rule in this profile, against the test vectors at /compliance/latest/test-vectors/request-signing/. To run the CLI grader against your own agent, see Auth Graders.
No general-purpose RFC 9421 response-signing profile. This profile signs the request; AdCP 3.x defines no general-purpose paired profile for signing the synchronous response transport. Sellers MUST NOT apply RFC 9421 §2.2.9 response signing to synchronous AdCP responses (whether MCP tools/call or A2A non-streaming responses including streaming artifactUpdate frames), and buyers MUST NOT rely on an RFC 9421 response signature on the synchronous reply. Integrity of the immediate response transport rests on TLS within the authenticated session that carried the request, modulo the standard edge-termination caveats that govern request-side body integrity at body-modifying CDNs. Durable at-rest attestation for artifacts that need to survive past the session — including specialism-scoped payloads (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts, bilateral non-repudiation receipts such as plan_receipt) — is the job of signed webhooks (adcp_use: "webhook-signing"). The split is deliberate — see Security Model: What gets signed for the full rationale and the request-the-webhook pattern for tools whose canonical artifact needs to be attestable.
Designated-task payload-envelope response signing. A closed list of tasks designates their response payload as cryptographically signed under adcp_use: "response-signing". The primitive is distinct from RFC 9421 §2.2.9 transport response signing on two load-bearing axes:
- Signature location: inside the response body, not in HTTP response headers.
- Verification path: parse the response body, then verify the JWS against the responding agent’s
response-signingJWK published at the agent’sjwks_uri— not RFC 9421 base reconstruction over transport headers.
verify_brand_claimand its bulk variantverify_brand_claims(Brand Protocol). The responding brand-agent signs its response payload as a JWS envelope under the brand’sadcp_use: "response-signing"key. The signature is load-bearing for the direction-asymmetric trust model — seeverify_brand_claimtrust model and Building a brand agent — Signing setup.
tag or adcp_use string they coin) are operating outside this profile and are not 3.x-conformant; the only response-signing primitive the spec authorizes in 3.x is payload-envelope JWS on the designated-tasks list.
The adcp_use: "response-signing" value is therefore reserved at the JWK layer for the payload-envelope primitive. Keys published with adcp_use: "response-signing" MUST sign only payload-envelope JWS as defined in this section; using such a key to produce RFC 9421 §2.2.9 transport signatures is a profile violation regardless of the task being signed. If a future major version scopes RFC 9421 transport response signing for any task, it MUST use a distinct adcp_use value (e.g., "response-transport-signing") so verifiers can disambiguate the primitive from the JWK alone — the brand-protocol value cannot be retconned to cover both. List growth and additional primitives are normative decisions deferred to future spec versions.
Designated-task success responses MUST carry a signed_response member matching response-payload-jws-envelope.json. The envelope payload is the canonical signed task-body object and MUST include typ: "adcp-response-payload+jws", task, brand_domain, agent_url, request_hash, iat, exp, and response. The outer task-body fields are convenience fields for ordinary task consumers; verifiers that rely on the signature MUST reject the envelope if any unsigned task-body field disagrees with signed_response.payload.response. Protocol/version envelope fields are excluded from this comparison, including status, context_id, task_id, message, timestamp, replayed, adcp_version, and adcp_major_version.
This profile uses ordinary JWS signing, not RFC 7797 unencoded payloads. The JWS Signing Input is BASE64URL(UTF8(protected)) || "." || BASE64URL(UTF8(JCS(payload))), where payload is signed_response.payload and protected decodes to { "alg": "EdDSA" | "ES256", "kid": "...", "typ": "adcp-response-payload+jws" }. The protected header MUST NOT contain b64. Response verifiers MUST enforce the shared JWS discovery and hardening rules from the AdCP JWS profile: allowed algorithms, use: "sig", key_ops containing "verify", exact adcp_use: "response-signing", missing-kid refetch, revocation checks, SSRF-safe JWKS fetches, and duplicate-key rejection before canonicalization.
request_hash is sha256: plus unpadded base64url SHA-256 of the JCS canonical request-binding object { task, brand_domain, agent_url, caller_identity, request }. caller_identity MUST be a typed canonical string derived from the authenticated transport or credential mapping, such as signed-agent-url:<agents[].url>, api-client-id:<seller-issued client id>, or mtls-san:<lowercased SAN>. If no authenticated caller identity exists, caller_identity is null and the verifier MUST treat the response as weaker evidence that is not bound to a caller.
brand_domain is a tenant-binding field, not an echo. A multi-brand agent MUST populate it from its server-side tenant resolution and the brand.json entry whose policy store produced the answer; it MUST NOT copy it from the request body. agent_url is the canonical URL of the responding agents[] entry whose response-signing JWK verifies the envelope. Online verifiers MUST reject envelopes at or after exp after applying only small clock-skew tolerance; audit verifiers MAY verify after exp, but only as historical evidence that the brand-agent signed that payload during the stated iat/exp window.
Response-signing keys are scoped by brand tenant as well as by purpose. A shared multi-brand fleet MUST publish distinct response-signing key material and distinct kid values for each brand_domain it serves, even when the same software and agent_url serve multiple brands. Cross-brand reuse of a response-signing JWK is a profile violation because it defeats tenant-bound replay analysis; this rule is stricter than ordinary cross-purpose separation and applies only to adcp_use: "response-signing" keys.
Transport scope
| Class | 3.0 | 4.0 |
|---|---|---|
Spend-committing (create_media_buy, update_media_buy, acquire_*, activate_signal) | Optional, capability-advertised | Required |
Reversible state changes (sync_creatives, update_creative_status) | Optional | Recommended |
Read / discovery (get_products, get_media_buy_delivery, list_*) | Not in scope | Not in scope |
TMP provider_endpoint_url requests | Out of scope (TMP has its own envelope) | Out of scope |
Quickstart: opt into request signing in 3.0
For implementers who want to pilot signing in 3.0 before the 4.0 flip: As an agent that signs requests:- Call
get_adcp_capabilitieson the target seller. Readrequest_signing.supported_forandrequired_forto see which AdCP operations the seller expects you to sign, and readrequest_signing.protocol_methods_supported_for/protocol_methods_required_forto see which JSON-RPC protocol methods (e.g.,tasks/cancel) the seller’s verifier covers. Readcovers_content_digest("required"/"forbidden"/"either") to see whether you must, must not, or may covercontent-digest. - Generate an Ed25519 keypair:
openssl genpkey -algorithm ed25519 -out signing-key.pem. - Export the public key as a JWK. Add
"kid","use": "sig","key_ops": ["verify"],"adcp_use": "request-signing", and"alg": "EdDSA". - Publish the JWK at your agent’s
jwks_uri(the URL declared on youragents[]entry in brand.json; defaults to/.well-known/jwks.jsonat your agent URL’s origin). - Configure your AdCP client with the private key and agent URL. Your SDK signs requests automatically for any operation listed in the seller’s
supported_fororrequired_forcapability and any JSON-RPC method listed inprotocol_methods_supported_fororprotocol_methods_required_for, honoring the seller’scovers_content_digestpolicy. SDKs SHOULD support pluggable signers so the private key can live in a managed key store (KMS / HSM / Vault) rather than in process memory — see Production key storage below. - Validate end-to-end with the conformance vectors at
/compliance/latest/test-vectors/request-signing/(published per AdCP version; source lives atstatic/compliance/source/test-vectors/request-signing/) — if your client produces signatures that match the positive vectors’expected_signature_base, you’re done.
- Advertise
request_signing.supported: trueinget_adcp_capabilities. Leaverequired_for: []during the pilot; add operations incrementally per counterparty. - Enable signature verification middleware on mutating routes. Implement the verifier checklist — all 14 checks (13 numbered steps plus sub-step 9a), short-circuit on first failure.
- Start in shadow mode (verify and log; do not reject on failure) for a pilot counterparty before populating
required_for. Surface verification failures in monitoring rather than operations for the first few weeks. - Run the conformance negative vectors against your verifier — each rejection MUST produce the vector’s stated
error_code. The vector’sfailed_stepis informational; an implementation that rejects with the correct error code is conformant even if its internal step numbering differs.
kid-membership check (full grace semantics deferred). This is acceptable for log-and-observe shadow mode because no request is being rejected on replay or digest failure. Before adding any operation to required_for, implement steps 11–13 — digest recompute (step 11), replay insert after success (step 13), and the full revocation-stale grace window (part of step 9). Flipping to enforce with an incomplete verifier surfaces replay and body-integrity gaps on live production traffic rather than in shadow logs. Do not skip ahead of step 1 — malformed signatures always reject, never fall back.
Production key storage
Where the signer’s private key lives is implementation-defined — the spec is concerned only with the bytes on the wire — but operators SHOULD avoid holding private signing keys in process memory in production. A process compromise leaks the signing key, and the only remedy is rotation across every counterparty that’s cached the public key (within their cache TTL). The recommended pattern: an SDK exposes a pluggable signer interface (e.g.,sign(payload: Uint8Array): Promise<Uint8Array>), and the operator’s adapter delegates the operation to a managed key store — AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault Transit, or an HSM. The key never leaves the managed store; the SDK builds the canonical signature base, the store signs it, the SDK assembles Signature and Signature-Input headers from the returned bytes. Wire format is identical to in-process signing.
Two implementation notes for adapter authors:
- ECDSA-P256 signatures returned by most KMS APIs are DER-encoded; this profile and RFC 9421 §3.3.1 require IEEE P1363 (
r‖s, 64 bytes for P-256). Convert at the adapter boundary. - Treat the KMS key as single-purpose. The
tagparameter in this profile protects verifiers, not signers — an operator who reuses the same KMS key for AdCP request-signing and any other signing protocol creates a cross-protocol oracle. Bind the KMS access policy (GCProles/cloudkms.signerscoped to the specific cryptoKey, AWSkms:Signconditioned on the key ARN) so only the AdCP signing path can invoke the key.
@adcp/sdk (TypeScript) ships a SigningProvider interface with sync/async parity, an in-memory provider for tests, and a GCP KMS reference adapter at examples/gcp-kms-signing-provider.ts. See the SDK signing guide for the full walkthrough.
Tripwire pattern — assert public key at init. Managed key stores can silently rotate (IAM policy swap, version disable, hostile substitution). If rotation happens without updating the published JWKS, verifiers fetching the unchanged kid will reject every signature with no clear error signal — the operator sees counterparty failures, not a KMS mismatch. The defense: commit the expected public key (SPKI bytes, base64-encoded) alongside the code, and at signer init byte-compare it against the key the store returns (getPublicKey() or equivalent). A mismatch fails loudly at startup rather than silently on every signed call. Rotation then becomes a deliberate two-step: update the pinned constant, set the new key version path, deploy.
Lifecycle: lazy init, not eager. Calling getPublicKey (or any KMS warm-up call) before the process binds its listener looks clean in review but has a dangerous failure mode: if KMS auth is misconfigured, gRPC / TLS retries inside the KMS client can block indefinitely, the process never opens its port, and the infrastructure health-check times out — surfacing a “service unreachable” alarm rather than the underlying KMS error. The correct lifecycle is lazy init on first sign: call the store the first time a request needs signing, cache the result only on success (never cache errors), and deduplicate concurrent first-call requests with an in-flight promise. Fail-fast misconfig detection belongs in a CI/CD pre-deploy probe that exercises the KMS path with the deployment target’s credentials before cutover — not at process startup.
One JWK per adcp_use — publication shape. The single-purpose rule applies to key material and to JWKS publication. An operator signing both AdCP requests and webhooks needs distinct key material and must publish two entries with the same JWK shape, distinct x, distinct kid, and distinct adcp_use. The value is a string, not an array — publishing "adcp_use": ["request-signing","webhook-signing"] on a single entry is a schema error that receivers will reject:
kid values also mean counterparties can cache and rotate the two keys independently.
AdCP RFC 9421 profile
This profile constrains RFC 9421 to a single canonical shape so cross-implementation interop is tractable. Covered components (REQUIRED on every signed request):| Component | Notes |
|---|---|
@method | Uppercase. |
@target-uri | Canonicalized per the algorithm below. Signer MUST apply canonicalization before computing the signature base; verifier MUST apply the same canonicalization to the received request before verifying. |
@authority | Lowercased host[:port], default ports (443 for https, 80 for http) stripped. |
content-type | Required on requests with bodies. |
content-digest | Governed by the verifier’s request_signing.covers_content_digest capability — see Content-digest and proxy compatibility. |
@target-uri canonicalization follows the AdCP URL canonicalization rules — eight steps applying RFC 3986 §6.2.2 (syntax-based normalization) and §6.2.3 (scheme-based normalization), plus UTS-46 Nontransitional IDN processing and IPv6 zone-identifier rejection. Signers and verifiers apply the same algorithm; malformed authorities rejected there map to request_target_uri_malformed on the signing path. The authoritative algorithm, conformance vectors, and pitfalls list live on that page — keeping this profile’s treatment thin prevents divergence between the signing-specific copy and the general-purpose copy.
@authority canonicalization produces host[:port] from the URL’s authority after the canonicalization algorithm’s host and port steps (lowercase host / IDN → ACE / IPv6 bracketing preserved; userinfo stripped; default port stripped). IPv6 hosts retain their brackets in @authority ([::1]:8443). Verifiers MUST derive @authority from the HTTP/2+ :authority pseudo-header when present, otherwise from the as-received HTTP/1.1 Host header — not from reverse-proxy routing state, load-balancer metadata, or any Host value a forward proxy may have rewritten in transit. When both :authority and Host are present on the as-received request (HTTP/2→HTTP/1.1 translating intermediaries are permitted to leave both by RFC 7540 §8.1.2.3, which requires equivalence but does not require stripping the source), verifiers MUST reject with request_target_uri_malformed if they are not byte-equal after canonicalization; pick-one behavior is a silent downgrade surface. Regardless of the source header, the canonicalized value MUST byte-for-byte match the authority component of the canonical @target-uri — the byte-match against the signed @target-uri is the load-bearing safety gate, because Host can itself be rewritten in transit. Mismatch rejects with request_target_uri_malformed. This closes a cross-vhost replay vector: an attacker who intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool (same cert SAN, different Host) will fail the authority-match check even though the signature covers @authority.
Signers that canonicalize and verifiers that canonicalize MUST produce identical bytes for the same logical request. If your 9421 library applies different rules, either configure it to match this profile or normalize before handing the URL to the library.
The canonicalization.json conformance set exercises every rule from the algorithm with fixed inputs and expected outputs, plus malformed-authority rejection cases. SDKs SHOULD run this set on every commit — canonicalization divergence between signers is silent until it isn’t, and then it’s a production interop bug that’s painful to diagnose.
Verifiers MUST reject signatures whose covered-component list omits any required component for the request type. Signers MUST NOT cover additional headers without coordination — extra components silently invalidate signatures across implementations that don’t include them.
Signature parameters (Signature-Input parameters, all REQUIRED):
| Parameter | Notes |
|---|---|
created | Unix seconds. Reject if more than 60 s in the future. |
expires | Unix seconds. MUST satisfy expires > created and expires − created ≤ 300 (5-minute max validity). Reject if past, with ±60 s skew tolerance. |
nonce | Base64url-encoded, unpadded (no trailing =). Verifiers MUST reject if the decoded byte length is less than 16 bytes, or if the value includes padding. This is how the ”≥ 128 bits of entropy” requirement is enforced in practice. |
keyid | Matches a kid in the signer’s published JWKS. |
alg | MUST be ed25519 or ecdsa-p256-sha256. Verifiers MUST enforce the allowlist independently of library defaults. |
tag | MUST be exactly adcp/request-signing/v1 — byte-for-byte match, no prefix matching, no case-folding. The tag sig-param MUST appear exactly once in Signature-Input; verifiers MUST reject duplicates. The tag namespace is how the profile versions; future versions bump the tag rather than mutating parameter semantics, and adcp/request-signing/v2 verifiers will reject v1 signatures and vice versa. |
request_signature_params_incomplete) if any is absent.
Algorithm naming — JWK vs RFC 9421. The two names for each algorithm differ by source spec. Implementations mix these up often enough to warrant a table:
| Algorithm | JWK alg (in JWKS) | RFC 9421 alg (in Signature-Input) |
|---|---|---|
| Ed25519 | EdDSA | ed25519 |
| ECDSA P-256 with SHA-256 | ES256 | ecdsa-p256-sha256 |
keyid and finds "alg": "EdDSA" on the JWK, the matching sig-param value is ed25519. Implementations should validate that the two match (JWK alg matches the sig-param alg by mapping table) in addition to verifying the allowlist on each independently. Edge-runtime rationale from the governance profile applies — ES256 is the edge-friendly alternative where EdDSA requires runtime configuration.
One signature per request. Verifiers MUST process exactly one Signature-Input label (conventionally sig1) and MUST ignore any additional labels present in the request. Intermediaries that need to re-sign a relayed request MUST replace the upstream labels rather than append to them. Full relay-chaining semantics (when a relay wants to preserve the originator’s signature) are tracked in #2324 and out of scope for 3.0.
Binary value encoding (Signature, Content-Digest). RFC 9421 §3.1 and §2.1.3 emit binary values as the RFC 8941 Structured Field sf-binary token (:<base64>:), and RFC 8941 §3.3.5 specifies the standard base64 alphabet (RFC 4648 §4) with +// and = padding. The AdCP profile OVERRIDES this: Signature and Content-Digest sf-binary values MUST be encoded with base64url without padding (RFC 4648 §5), producing tokens whose inner bytes draw from [A-Za-z0-9_-] with no trailing =.
Rationale: URL-safe, pad-free, and symmetric with the nonce sig-param which is already specified base64url-unpadded. It avoids the two interop hazards of standard base64 in HTTP header values — / that some proxies rewrite and = that some header parsers treat as a structured-field parameter delimiter.
Verifier requirements:
- Signers MUST emit base64url-no-padding only. A signer that emits a
SignatureorContent-Digestvalue containing+,/, or=is non-conformant. - Verifiers MUST accept base64url-no-padding. Verifiers SHOULD ALSO lenient-decode pure standard-base64 tokens (translate
+→-then/→_, then strip any trailing=, then base64url-decode) for interop with counterparties that predate this clarification. This lenience is a compatibility affordance scheduled for removal in AdCP 3.2 — signers relying on it MUST migrate to base64url-no-padding before then. - Verifiers MUST reject any token that mixes alphabets (any character in
[+/=]AND any character in[-_]within the same token value) withrequest_signature_header_malformed. Mixed-alphabet tokens are ambiguous:A+B-could decode to different bytes depending on the order of “translate standard-base64 chars” and “base64url-decode” steps, and differingContent-Digestbytes across verifiers let an attacker stage a digest mismatch that one verifier accepts and another rejects. - The
expected_signature_basefield in the conformance vectors is independent of binary-value encoding — it contains the canonical signature base bytes, not any header-field encoding. Only the emittedSignaturetoken itself is encoded.
Content-Digest from non-AdCP upstreams. RFC 9530 §2 defines Content-Digest and defers sf-binary to RFC 8941 (standard base64), so a conformant 9530 emitter from another ecosystem (a CDN, a non-AdCP framework) may populate Content-Digest on an inbound request using the RFC 8941 default. The AdCP override above applies to signed AdCP requests; verifiers processing such a request MUST use the override rules. Verifiers handling unsigned traffic or Content-Digest from non-AdCP upstreams MAY accept either encoding — this is outside the signing profile’s scope.
Operation names in required_for / supported_for are AdCP protocol operation names (create_media_buy, update_media_buy, acquire_rights, etc.) — not MCP tool names, A2A skill names, or any transport-specific rename. Verifiers MUST NOT accept operation names that are not defined by the AdCP protocol spec. This is how cross-transport verifiers agree on what “signed for create_media_buy” means.
Protocol-method coverage (protocol_methods_*). AdCP operations are not the only mutating surface a counterparty calls: A2A 0.3.0 §7.x defines task-lifecycle methods (tasks/cancel, tasks/get, tasks/resubscribe) that traverse the same authenticated channel, and the MCP transport auto-registers the same tasks/* JSON-RPC methods when an SDK task store is wired. Sellers declare verifier coverage of these methods in a separate namespace from the AdCP operation list:
| Field | Contents | Match semantics |
|---|---|---|
request_signing.protocol_methods_supported_for | JSON-RPC method strings (e.g., "tasks/cancel") | Verifier accepts and validates a signature when the JSON-RPC method field of the inbound request matches. |
request_signing.protocol_methods_warn_for | Same | Shadow-mode mirror of warn_for: log failures, do not reject. |
request_signing.protocol_methods_required_for | Same | Reject unsigned matches with request_signature_required. |
method field (tasks/cancel, tasks/get, …), not the MCP tools/call params.name. AdCP tool names (no /) MUST NOT appear in any protocol_methods_* array, and JSON-RPC method names (containing /) MUST NOT appear in supported_for / warn_for / required_for. Verifiers MUST reject capability blocks that violate the namespace split with a configuration-time error rather than silently coercing strings between the two. Verifiers MUST NOT cross-namespace match: a protocol_methods_required_for membership MUST NOT be satisfied by a body whose JSON-RPC method is tools/call (even if params.name happens to equal a listed method string), and a required_for membership MUST NOT be satisfied by a body whose JSON-RPC method is anything other than tools/call. The two buckets are matched against disjoint envelope fields.
The signature-base construction is identical for both namespaces: the same RFC 9421 covered components apply (@target-uri, @method, content-digest per the seller’s covers_content_digest policy, authorization when present), with @target-uri and @method reflecting the actual HTTP request — not the JSON-RPC method string. Buyers signing a tasks/cancel POST sign exactly as they would for any other mutating call; the only thing the new fields change is the seller’s declaration of which JSON-RPC methods are in scope for verification.
Cross-namespace replay risk on shared transport. When a single @target-uri accepts both tools/call envelopes and JSON-RPC protocol methods (the canonical MCP layout — both POST to /mcp), @target-uri and @method alone do not bind which JSON-RPC method the body invokes; the method field lives in the body. Without content-digest coverage, an on-path attacker who captures a signed tools/call request can swap the body to {"method":"tasks/cancel",...} (or vice-versa) within the signature window and the verifier will accept it. Sellers that populate protocol_methods_required_for (or any protocol_methods_*) on a transport shared with tools/call therefore SHOULD set covers_content_digest: 'required' so the body — and through it the JSON-RPC method — is bound to the signature. Sellers that cannot adopt 'required' MUST mount AdCP and protocol-method traffic on distinct @target-uris so that @target-uri itself partitions the namespaces.
Buyers reading capability blocks in 3.x MUST NOT assume protocol-method coverage from supported_for / required_for: a seller that lists create_media_buy in required_for and is silent on protocol_methods_* is not declaring tasks/cancel coverage. Buyer SDKs that sign tasks/cancel opportunistically (the only defensible default when the seller is silent) MAY do so without violating the spec, but interoperable enforcement only emerges once the seller populates protocol_methods_supported_for or protocol_methods_required_for.
Agent key publication
Request-signing and webhook-signing keys live at the signing agent’s ownjwks_uri in its operator’s brand.json agents[] entry. Every agent that signs — of any type — uses the same publication pattern. Publisher adagents.json may additionally pin allowed seller keys through authorized_agents[].signing_keys[]; when present, that pin is authoritative for the scoped sell-side authorization.
Publisher pin precedence. When a publisher’s adagents.json entry for an authorized agent carries a signing_keys pin (see adagents.json §signing_keys), that pin is authoritative: verifiers MUST reject any signature whose keyid is not in the pinned set, regardless of jwks_uri contents. The agent-hosted JWKS is advisory whenever a publisher pin exists. This closes the agent-domain-compromise window — an attacker who takes over the agent’s domain cannot silently swap both the endpoint and its advertised keys because the publisher’s pin still governs acceptance. Publishers are required to pin for any agent whose delegated scopes include mutating operations; see the adagents.json rule for rotation and cache semantics.
Each request-signing JWK entry MUST declare:
| Member | Value | Notes |
|---|---|---|
use | "sig" | Standard JWK signing use. |
key_ops | ["verify"] | Verifier-visible JWKS declares verify-only. The signing operator holds the corresponding private key locally with ["sign"] per JWK spec. |
adcp_use | "request-signing" | AdCP-specific purpose discriminator. Distinguishes from "governance-signing" (JWS profile), "webhook-signing" (seller→buyer webhook callbacks), and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different adcp_use when verifying a request signature. Sellers that also sign webhooks publish a separate "webhook-signing" key in their operator JWKS — see Webhook callbacks. |
kid | distinct | Unique within the JWKS. MUST NOT collide with any other entry’s kid regardless of adcp_use. |
alg | "EdDSA" or "ES256" | Must match the signature’s alg parameter (JWK alg uses JWS names; alg in Signature-Input uses RFC 9421 names). |
adcp_use: a single JWK entry can only declare one adcp_use value, so a publisher cannot accidentally (or deliberately) present a governance-signing key as a valid request-signing key. Verifiers check adcp_use on the JWK they fetched, not across other JWKS endpoints — no cross-endpoint lookup is required or permitted.
Origin separation (MUST for governance, SHOULD for others). adcp_use is an in-band discriminator — it prevents cross-purpose verification, but it does not defend the publishing origin. An origin compromise on a shared JWKS endpoint simultaneously compromises every signing purpose it publishes. Because a governance-signing key is the highest blast-radius key in the system (its compromise is a multi-tenant breach), governance signing keys MUST be served from a separate origin than transport-signing and webhook-signing keys. The canonical pattern is:
governance-keys.{org}.example/.well-known/jwks.json— governance-signing JWKs onlykeys.{org}.example/.well-known/jwks.json— request-signing, webhook-signing, TMP keys
identity.key_origins map in get_adcp_capabilities; the schema defines governance_signing, request_signing, webhook_signing, and tmp_signing origin URIs. Implementers SHOULD populate the field so counterparties can verify origin separation at onboarding. When the field is present, verifiers MUST check that the declared governance-signing origin differs from the declared transport-signing and webhook-signing origins at onboarding and reject onboarding with a user-actionable error on co-tenancy. The MUST on origin separation is otherwise unverifiable on the wire — the whole point of publishing the advertisement is to let counterparties enforce it programmatically; accepting a declaration that violates the normative rule would defeat the control. Verifiers MAY additionally fetch each declared JWKS and confirm its jwks_uri origin matches the advertised value.
Implementer note: adcp_use is a custom JWK member. Major JOSE libraries (jose, node-jose, python-jose, go-jose) preserve unknown members on parse. Strict JWK validators (some modes of PyJWT, and Web Crypto API’s SubtleCrypto.importKey) may reject unknown members. When handing a JWK to SubtleCrypto.importKey or equivalent strict consumers, strip adcp_use from the JWK object but retain it for the step-8 policy check. The field is for AdCP verifier policy, not for cryptographic libraries.
JWKS discovery for a signed request — given a keyid on an incoming signature:
- The verifier resolves the signing agent’s URL to its brand.json
agents[]entry. Discovery MAY come from prior onboarding, MAY come from a registry cache, but the canonical on-wire bootstrap is theidentity.brand_json_urlfield on the agent’sget_adcp_capabilitiesresponse — see Discovering an agent’s signing keys viabrand_json_url. - Fetch the agent’s
jwks_uri(or default to/.well-known/jwks.jsonat the origin of the agent’surl) with SSRF validation per Webhook URL validation. JWKS cache TTL bounded above by the revocation-list polling interval. - If the
kidis absent from the cached JWKS, refetch the JWKS immediately (step 2’s first fetch may have been cached). If a refetch was already performed in the last 30 seconds for the samejwks_uri, the cooldown applies: the verifier MUST NOT refetch again and MUST reject withrequest_signature_key_unknown. The cooldown is between refetches, not before the first.
keyid they cannot resolve to a specific agents[] entry — anonymous signatures provide no accountability.
Discovering an agent’s signing keys via brand_json_url
The identity.brand_json_url field on get_adcp_capabilities (added in 3.x, see schema static/schemas/source/protocol/get-adcp-capabilities-response.json) is the on-wire bootstrap for the agent → operator → keys chain. The field name reflects the artifact it points at (the operator’s brand.json file), independent of whether the operator structure is a single brand, a house with sub-brands, an agency, or a pure operator record. Given only an agent URL A, a verifier resolves the agent’s signing keys via:
- Fetch
A’sget_adcp_capabilitiesresponse with SSRF validation per Webhook URL validation (HTTPS only — the URLAis supplied by the caller and MUST go through the same address-family + private-IP filtering used for webhook callbacks). On unreachable/timeout, reject withrequest_signature_capabilities_unreachable. - Read
identity.brand_json_url. If absent and the request is signed, reject withrequest_signature_brand_json_url_missing. Reject with the same code if the value is non-HTTPS (the schema enforces^https://but verifiers MUST restate the check; a 3.x parser tolerating a malformed value MUST NOT proceed). The required-when rule is:identity.brand_json_urlMUST be present when the agent declaresrequest_signing.supported_for/required_fornon-empty,webhook_signing.supported === true, or any field underidentity.key_origins. This is storyboard-enforced in 3.x. In 4.0 the rule becomes schema-required when the response declaressupported_versionscontaining any 4.x release; cross-version verifiers (4.0 talking to a 3.x agent that does not advertise 4.x support) MUST continue to accept absentidentity.brand_json_url. - Origin binding. The agent URL
A’s host eTLD+1 MUST equal thebrand_json_url’s host eTLD+1. eTLD+1 computation MUST use a pinned, dated Public Suffix List snapshot (ICANN+PRIVATE sections both in scope so platforms likevercel.app,pages.dev,github.ioare treated as suffixes); two verifiers running different PSL versions are non-conformant against each other. If eTLD+1 mismatches, fetch brand.json and check thatauthorized_operators[]listsA’s eTLD+1. If neither holds, reject withrequest_signature_brand_origin_mismatch. This closes the shared-tenancy spoofing vector where an attacker stands up an agent onattacker.example/mcpand points itsbrand_json_urlat an unrelated operator’s brand.json that happens to legitimately listattacker.example/mcp(e.g., a SaaS multi-tenant deployment). - Fetch brand.json at
brand_json_urlwith SSRF validation per Webhook URL validation. Verifiers MUST NOT follow redirects on this fetch (the single-redirect carve-out forauthoritative_locationdocumented elsewhere in this profile is scoped to that field and MUST NOT be inherited by the brand.json bootstrap). Recommended budgets: connect 5 s, total deadline 10 s, body cap 256 KiB. Cache TTL on a successful fetch MUST be bounded above by the JWKS revocation polling interval (so a key rotation cannot be masked by a stale brand.json). Negative responses (404, network failure) MUST NOT be cached for more than 60 s — operators fixing a misconfiguration must not be locked out for a full revocation cycle. - Find the entry in
agents[]whoseurlbyte-equalsA(no canonicalization at this step — same rule as theiss-to-brand.json match for governance JWS, see Buyer identity resolution; the most common failure mode is a trailing-slash or scheme mismatch, e.g.https://x.com/mcp≠https://x.com/mcp/). If none matches, reject withrequest_signature_agent_not_in_brand_json. If multiple match (operator misconfig — the brand.json schema does not currently constrainagents[]to be unique-by-URL), reject withrequest_signature_brand_json_ambiguous. - Resolve the JWKS source by purpose AND role (sender-vs-receiver position, not just signing purpose):
- Sell-side webhook-signing only — i.e., the seller signing an outbound webhook to the buyer about media-buy delivery: the publisher’s
adagents.json signing_keyspin (when present) is authoritative per the publisher-pin precedence rule above and overrides everything below. The pin is scoped to (agent,webhook-signingpurpose, sell-side role) — it does NOT override operator-side webhook-signing (e.g., a buyer-hosted webhook receiving operator status callbacks). - All other (purpose, role) tuples — request-signing (any direction), operator-side webhook-signing, governance-signing, TMP-signing: use the matched
agents[]entry’sjwks_uri, defaulting to/.well-known/jwks.jsonat the origin ofAwhen absent.
- Sell-side webhook-signing only — i.e., the seller signing an outbound webhook to the buyer about media-buy delivery: the publisher’s
identity.key_originsconsistency check (mandatory when signing). For everypurposedeclared underidentity.key_originson the capabilities response whose JWKS source in step 6 was the operator brand.json (i.e., not a publisheradagents.json signing_keyspin), the host of the resolvedjwks_uriMUST equal the declared origin for that purpose. Mismatch on any purpose → reject withrequest_signature_key_origin_mismatchcarrying{ purpose, expected_origin, actual_origin }. Skip the check only for the specific (agent, purpose, role) tuple whose source was a publisher pin — operator-side use of the same purpose is still checked. If the agent declares signing without a correspondingidentity.key_origins.{purpose}entry, reject withrequest_signature_key_origin_missingcarrying{ purpose, posture }.- Fetch JWKS, find the
kid, verify per the existing RFC 9421 profile (steps 7+ of the verifier checklist).
adagents.json is publisher-attested (“this agent may sell my inventory; optionally, here is its pinned signing_keys”). For sell-side webhook signatures, the publisher pin is authoritative (publisher > operator). For request signatures and operator-side webhook signatures, the operator brand.json jwks_uri is authoritative. The agent never self-attests its own keys — a jwks_uri field is deliberately NOT carried on the capabilities response; the operator publishes the keys out-of-band via brand.json.
sponsored_intelligence.brand_url is distinct. SI agents may carry a brand_url field under sponsored_intelligence for rendering purposes (colors, fonts, logos, tone) — the field is named brand_url because, in the SI context, it really is “the brand being advertised.” That field is a rendering pointer, not a trust-root pointer; an SI agent MAY set its sponsored_intelligence.brand_url to a different URL than its identity.brand_json_url (e.g., a sub-brand brand.json for rendering while still trusting the operator’s brand.json for keys). Verifiers MUST use identity.brand_json_url for key discovery; sponsored_intelligence.brand_url MUST NOT be used as a trust-root pointer even when identity.brand_json_url is absent. A verifier consuming SI rendering metadata MAY read sponsored_intelligence.brand_url; the same verifier MUST switch to identity.brand_json_url for any signature-verification flow. The naming distinction is deliberate: brand_url for “the brand being advertised” contexts; brand_json_url for “the operator master record” contexts.
Rejection codes for this discovery chain (3.x). Detail fields sourced from a counterparty document (brand_json_url, matched_entries[]) MUST be HTML-escaped before rendering in admin UIs that display verifier errors — they are attacker-influenceable strings, even though the structured shape is verifier-controlled.
| Code | When | Detail fields | Remediation |
|---|---|---|---|
request_signature_brand_json_url_missing | Capabilities did not carry identity.brand_json_url and a signed request was received, or carried a non-HTTPS value | agent_url | Operator: set identity.brand_json_url to the HTTPS URL of your operator brand.json (typically https://{your-domain}/.well-known/brand.json). Verifier: surface to operations; do not retry. |
request_signature_capabilities_unreachable | Capabilities fetch failed (DNS, TCP, TLS, timeout, non-2xx) | agent_url, http_status, dns_error, last_attempt_at | Verifier MAY retry once after a 1–5 s jittered backoff, then give up; do not negative-cache for more than 60 s. Surface as transient. |
request_signature_brand_json_unreachable | brand.json fetch failed (same conditions) | brand_json_url, http_status, dns_error, last_attempt_at | Same retry/cache discipline as _capabilities_unreachable. |
request_signature_brand_json_malformed | brand.json failed strict-parse (duplicate keys, body cap exceeded, or non-JSON content) | brand_json_url, parse_error | Operator: serve a strict-JSON brand.json with no duplicate object keys and within the 256 KiB body cap. Verifier: do not retry; surface to operations. |
request_signature_brand_origin_mismatch | Agent eTLD+1 ≠ brand_json_url eTLD+1 and authorized_operators[] does not delegate | agent_url, agent_etld1, brand_json_url_etld1 | Operator: either move agent to brand eTLD+1, or add agent eTLD+1 to brand.json authorized_operators[]. Not retryable. |
request_signature_agent_not_in_brand_json | Agent URL not byte-equal to any agents[].url of resolved brand.json | agent_url, brand_json_url | Operator: add agent URL byte-equal to agents[].url. Common cause: trailing slash, scheme mismatch, IDN/punycode normalization. Not retryable. |
request_signature_brand_json_ambiguous | Multiple agents[] entries match the agent URL | agent_url, brand_json_url, matched_count, matched_entries[] | Operator: dedupe agents[] entries by URL. Not retryable. |
request_signature_key_origin_mismatch | Resolved jwks_uri host ≠ declared identity.key_origins.{purpose} | purpose, expected_origin, actual_origin | Operator: align identity.key_origins.{purpose} with the host of the resolved jwks_uri. Not retryable. |
request_signature_key_origin_missing | Signing posture declared but identity.key_origins.{purpose} absent | purpose, posture | Operator: add identity.key_origins.{purpose} declaration to capabilities. Not retryable. |
Adopting
brand_json_url while pinned to AdCP 3.0. The field lands in 3.x’s next minor as a strictly-additive schema change; AdCP doesn’t ship new fields in patch releases (3.0.x), so a formal backport isn’t on the table. But you don’t have to wait for the version bump to start using it. The wire shape is forward-compatible:- A 3.0-conformant seller MAY populate
identity.brand_json_urlon itsget_adcp_capabilitiesresponse today. A 3.0 verifier ignoring the field continues to work; a 3.x verifier picks it up automatically. No coordination, no version bump. - A 3.0-conformant verifier MAY read the field opportunistically (via
caps.identity?.brand_json_url) and run the 8-step chain when present, falling back to your existing out-of-band agent → operator mapping when absent. The chain itself is just HTTPS fetches and JSON parsing — nothing in it requires a 3.x SDK.
Quickstart: implement a brand_json_url-based verifier
Mirrors the request-signing quickstart above. Run-once-per-agent — the resulting agents[] entry, jwks_uri, and JWKS are cached per the TTL rules in step 4.
- Fetch capabilities for the signing agent’s URL
A. This is a protocol-level call — invokeget_adcp_capabilitiesvia the agent’s declared transport (MCPtools/callor A2A skill invocation), not a raw HTTPGETagainstA. The agent URL is the protocol endpoint, not a JSON capabilities document. Use SSRF-safe transport per Webhook URL validation: HTTPS only, address-family + private-IP filtering, no redirects, with budgets{ connect: 5000, total: 10000, body: MAX_CAPABILITIES_BYTES, maxRedirects: 0 }. - Read
identity.brand_json_url. Rejectrequest_signature_brand_json_url_missingif absent (and the request is signed) or non-HTTPS. - eTLD+1 origin binding. Compute
eTLD+1(A)andeTLD+1(brand_json_url)using a pinned PSL snapshot. Usetldts(TS),publicsuffixlist(Python), orgolang.org/x/net/publicsuffix(Go) with a vendored, dated snapshot. Do NOT fetch the PSL at runtime — a runtime fetch creates a denial-of-service oracle and a non-deterministic eTLD+1 across deployments. If they match, proceed. Otherwise fetchbrand.jsonand checkauthorized_operators[]— ifeTLD+1(A)is delegated, proceed. Else rejectrequest_signature_brand_origin_mismatch. Origin comparisons throughout this algorithm MUST canonicalize both sides: ASCII-lowercase the host, then convert to IDNA-2008 A-label form (Punycode) before byte-equality. A non-canonical comparison (e.g., rawExample.COMvsexample.com, or U-label vs A-label) silently rejects legitimate traffic. - Fetch
brand.jsonwith the same SSRF rules + no redirects, body capMAX_BRAND_JSON_BYTES, connect 5 s, total 10 s. Parse with a strict JSON parser that rejects duplicate keys (e.g.,secure-json-parsein TS, the stdlibjson.JSONDecoderin Python with anobject_pairs_hookthat raises on duplicates,encoding/jsonDecoder.DisallowUnknownFieldspaired with a duplicate-key check in Go) — duplicate keys are the parser-differential vector that step 14 closes on the request surface, and the same trust-root document MUST NOT parse to two different shapes across verifiers. On duplicate-key detection, rejectrequest_signature_brand_json_malformed. Cache successful responses up to (but no longer than) the JWKS revocation polling interval; cache failures for at most 60 s. - Find the
agents[]entry whoseurlbyte-equalsA(no canonicalization). Rejectrequest_signature_agent_not_in_brand_jsonon miss;request_signature_brand_json_ambiguouson multiple matches. - Resolve
jwks_urifrom the matched entry — for sell-side webhook-signing only, prefer the publisher’sadagents.json signing_keyspin (when present) over the operator’sjwks_uri. For all other (purpose, role) tuples, use the matched entry’sjwks_uri(default:/.well-known/jwks.jsonat the origin ofA). - Consistency check. For every purpose declared under capabilities
identity.key_origins, applycanonicalizeOrigin()(ASCII-lowercase + IDNA-2008 A-label) to both the resolvedjwks_urihost and the declared origin, then byte-compare (skip only the specific (agent, purpose, role) tuple sourced from a publisher pin). Rejectrequest_signature_key_origin_mismatch/_missingas appropriate. - Hand off to step 8+ of the verifier checklist — fetch the JWKS (with the same byte budget
MAX_JWKS_BYTESand 5/10 s connect/total deadlines), find thekid(already resolved here in step 7’s preamble — the verifier checklist’s step 7 is the discovery preamble itself), verify per RFC 9421.
/compliance/latest/test-vectors/brand-discovery/ once published; until then, the storyboard at /compliance/latest/universal/capabilities-brand-url-discovery/ exercises the verifier algorithm against fixture brand.json + JWKS and asserts the right request_signature_* codes for each error path.
Reference implementations
The 8-step algorithm ships in three SDKs — pick the one matching your runtime. All three return the same logical record: the agent URL, the resolved brand.json URL, the matchedagents[] entry, the JWKS URI, the JWKS itself, the identity_posture block from the capabilities response, an consistency flag from the step-7 key_origins check, a freshness timestamp set, and a per-step trace.
- TypeScript (
@adcp/sdk):resolveAgent(url)returns{ agentUrl, brandJsonUrl, agentEntry, jwksUri, jwks, identityPosture, consistency, freshness, trace }.getAgentJwks(url)is the JWKS-only fast path.createAgentJwksSet(url, opts)returns aJWTVerifyGetKeyfor handing tojose’sjwtVerify. - Python (
adcp):resolve_agent(url)returns anAgentResolutiondataclass with fieldsagent_url,brand_json_url,agent_entry,jwks_uri,jwks,identity_posture,consistency,freshness,trace.verify_request_signature(request, *, agent_url, allowed_algs)is the one-shot helper that runs the discovery chain and the verifier checklist in one call. - Go (
adcp-go):ResolveAgent(ctx, agentURL) (*AgentResolution, error)returns a struct with fieldsAgentURL,BrandJSONURL,AgentEntry,JWKSUri,JWKS,IdentityPosture,Consistency,Freshness,Trace.VerifyRequestSignature(ctx, req, opts) (*VerifiedIdentity, error)mirrors the TS/Python one-shot.
npx @adcp/sdk@latest resolve <url>, adcp resolve <url> (also python -m adcp resolve <url>), adcp resolve <url> (Go binary, same name as the Python one — disambiguate by $PATH or vendor) — printing the trace with per-step fetched_at/age_seconds/ok so an operator triaging a request_signature_brand_* failure can see exactly which step rejected and why. Both the Python ([project.scripts] console_scripts entry) and Go (binary adcp, distinct from the Go module path github.com/adcontextprotocol/adcp-go) toolchains install a top-level adcp command so a single muscle-memory invocation works across runtimes.
Agent identity
A valid signature establishes exactly one fact: the request was issued by the agent whosejwks_uri contains the keyid. The verifier learns which specific agent signed, not just which operator. The agent’s containing brand.json (discovered via the verifier’s existing agent mapping) tells the verifier which operator runs that agent.
agent_url derivation. The canonical buyer-agent identifier on the verifier’s request context is the url field of the agents[] entry whose jwks_uri resolved the keyid at step 7 of the verifier checklist. agent_url is not a JWK claim, JWS claim, or signed envelope field — it is the publication coordinate the verifier already used to fetch the JWKS. This makes derivation deterministic from inputs the verifier has fully controlled (the agent mapping established at onboarding, plus the JWKS it just fetched) and removes any wire affordance for the signer to assert a different agent_url than the one whose key signed the request. SDKs that surface a resolved-signer object to adopters MUST source agent_url from this derivation; they MUST NOT accept a buyer-asserted agent_url field on the envelope and treat it as cryptographically established. (Buyer-asserted verifier references like creative.verify_agent.agent_url and governance.accepted_verifiers[].agent_url are a separate construct — they name agents the seller will invoke under a published allowlist, not the signer of the inbound request, and remain permitted.)
Authorization — whether this operator is permitted to act for the brand named in the request body — is a separate protocol-level check governed by the target house’s brand.json authorized_operator[] entries. It happens whether the request is signed or not, and is outside the scope of this profile. Verifiers MUST perform both checks; this section specifies only the first.
Verifiers MUST NOT derive signer identity from request body fields. The signature → JWKS → agent entry chain is the only authoritative identity path on the signed transport. On the bearer / API-key / OAuth transport, agent identity comes from the seller’s credential-to-agent mapping in its onboarding record — that mapping is the only legitimate identity source. Sellers MUST NOT introduce an envelope-side buyer_agent_url (or equivalent self-asserted caller-identity field) as an alternate input to identity resolution: the wire affordance lets a caller assert an identity the credential map would not, with no offsetting check.
brand.json discovery follows one redirect (authoritative_location) and stops.
Verifier checklist (requests)
Before applying the checklist, verifiers MUST determine whether the operation requires a signature:- If the operation is in the verifier’s
required_forcapability, AND noSignature-Inputheader is present, AND the caller presents no other credential the verifier accepts for this operation (bearer, API key, or mTLS), THEN reject withrequest_signature_required. Unsigned requests that fall into this branch never enter the checklist. See Composition with fallback authenticators for the rule governing unsigned-but-otherwise-authenticated callers. - If either
SignatureorSignature-Inputis present without the other, reject withrequest_signature_header_malformed. The two headers are a bound pair; one without the other is malformed, not “signed with a missing piece we can guess at.” This rule closes a downgrade vector where a proxy stripsSignature-Inputbut leavesSignature. - If a
Signature-Inputheader is present but malformed, reject withrequest_signature_header_malformed. Verifiers MUST NOT fall back to bearer-only authentication when a malformed signature is present, even for operations not inrequired_for— a present-but-broken signature signals signer intent; silent fallback enables downgrade attacks.
-
Parse
Signature-InputandSignatureheaders per RFC 9421 §4. Reject if malformed. -
Reject if any of
created,expires,nonce,keyid,alg, ortagis absent from theSignature-Inputparameters (request_signature_params_incomplete). -
Reject if
tagis not exactlyadcp/request-signing/v1(request_signature_tag_invalid). -
Reject if
algis not in the allowlist (ed25519,ecdsa-p256-sha256). Library defaults MUST NOT be relied upon (request_signature_alg_not_allowed). -
Reject if
expires ≤ created,created > now + 60 s,expires < now − 60 s, orexpires − created > 300 s(request_signature_window_invalid). -
Reject (
request_signature_components_incomplete) if covered components do not include all of:@method,@target-uri,@authority. If a body is present, reject ifcontent-typeis not covered. If the verifier’scovers_content_digestcapability is"required", reject ifcontent-digestis not covered. If the verifier’scovers_content_digestcapability is"forbidden"andcontent-digestIS covered, reject withrequest_signature_components_unexpected. -
Resolve
keyidto a JWK via Agent key publication. If the verifier has no cached agent → JWKS mapping for the signing agent, run Discovering an agent’s signing keys viabrand_json_urlbefore this step — its 8-step preamble (capabilities →identity.brand_json_url→ brand.json → agents[] → jwks_uri) is a precondition forkeyidresolution and short-circuits with therequest_signature_brand_*andrequest_signature_key_origin_*codes from that section. Onkidmiss within an established mapping, refetch once (subject to the 30-second cooldown between refetches) before rejecting withrequest_signature_key_unknown. Reject ifkeyidcannot be resolved to a specificagents[]entry. -
Verify the JWK’s
useis"sig",key_opsincludes"verify", andadcp_useequals"request-signing". Reject (request_signature_key_purpose_invalid) on any mismatch — including absentadcp_use, which MUST be treated as non-conforming. -
Check the Transport revocation list. Reject if
keyid∈revoked_kids(request_signature_key_revoked). Reject withrequest_signature_revocation_staleif the verifier has not refreshed the revocation list within grace. 9a. Per-keyid cap check. Check the per-keyid replay-cache cap. Reject withrequest_signature_rate_abuseif the cap has been reached for thiskeyid. Runs before cryptographic verify (step 10) — same rationale as step 9: a compromised or misconfigured signer exhausting its cap MUST NOT force amplified Ed25519/ECDSA work on the verifier. Runs afterkeyidresolution (step 7) so the cap-state oracle only responds for keys the verifier has already committed to recognizing — running 9a earlier would let an attacker probe verifier-internal rate-limit state across the full keyid space, including keyids not published in JWKS. -
Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying
@target-uricanonicalization AND@authorityderivation per the profile above. The@authorityrule is load-bearing: verifiers MUST derive@authorityfrom the HTTP/2+:authoritypseudo-header when present, otherwise from the as-received HTTP/1.1Hostheader — NOT from reverse-proxy routing state, load-balancer metadata, or anyHostvalue a forward proxy may have rewritten in transit. If both:authorityandHostare present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects withrequest_target_uri_malformed. The canonicalized@authorityMUST byte-for-byte match the authority component of the canonical@target-uri; mismatch rejects withrequest_target_uri_malformed. That byte-match against the signed@target-uri— not the choice of source header — is the only safe gate, becauseHostitself can be rewritten in transit. Implementers building from this checklist alone — without cross-referencing the profile’s canonicalization section — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool: same cert SAN, differentHost). After canonicalization completes, verify the signature against the JWK (request_signature_invalidon failure). -
If
content-digestis covered, recompute the digest from the received body bytes and compare (request_signature_digest_mismatchon mismatch). -
Check the nonce against the replay cache (see Transport replay dedup). Reject if
(keyid, nonce)has been seen within the replay-cache TTL (request_signature_replayed). -
Only after steps 1–9, 9a, and 10–12 have all passed, insert
(keyid, nonce)into the replay cache with TTL =(expires − now) + 60 s(the +60 s matches the skew tolerance applied at step 5). This insert MUST happen before the body-well-formedness check at step 14 so that a captured frame carrying a valid signature over a malformed body cannot be replayed to burn crypto-verify CPU on each retry — the nonce is burned on first sighting of a cryptographically-valid frame, regardless of body shape. -
Body well-formedness. Verifiers MUST reject bodies containing duplicate object keys (
request_body_malformed). Per RFC 8259 §4, duplicate-key parse behavior is unpredictable — the signature is valid over the bytes on the wire, but two parsers can disagree on the parsed value, which is a parser-differential attack class (cf. CVE-2017-12635). This check closes the gap between the signature verifier’s view of the payload and the downstream consumer’s view. Request bodies carry state-change and spend-committing payloads (create_media_buy,update_media_buy_delivery, etc.) whose parser-differential blast radius is larger than webhooks’ status-flip blast radius, making this check at least as load-bearing here as on the webhook surface.request_body_malformedis distinct fromrequest_signature_digest_mismatch: the signature IS valid; the body parses to ambiguous state. A verifier that crashes rather than returning a structuredrequest_body_malformederror is conformant-but-suboptimal — senders receive no actionable error code. Idempotency_key coverage follows from this check: step 14 runs before schema validation and idempotency-cache lookup (see idempotency), so a request body whoseidempotency_keyis itself duplicated (different parsers seeing different keys) is rejected here and never reaches the cache. No separate idempotency-layer audit is required. 14a. Strict-parse requirement. The check MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. The per-language strict-parse escape-hatch enumeration in step 14a of the webhook verifier checklist applies identically here. 14b. Logging discipline. Verifiers SHOULD NOT log full request body bytes on arequest_body_malformedrejection; logkeyid, nonce, byte length, and the specific duplicate key names only. The key-name sanitization rules (truncate at first non-printable to<sanitized:N>, truncate to last UTF-8 codepoint at or below 32 bytes, cap count at 4) from step 14b of the webhook verifier checklist apply identically here — the attacker-controlled-byte channel has the same shape on the request surface.
verified_signer: { keyid, agent_url, verified_at } on the request context so downstream code — including the subsequent brand-operator authorization check — can log and audit by signed agent identity.
Cheap rejections before crypto verify (steps 9 and 9a before step 10) are deliberate. If a verifier checks crypto first, an attacker replaying a revoked-key signature — or a signer hammering a verifier whose per-keyid cap is full — forces an Ed25519 or ECDSA verification on every rejection, cheap amplification. Moving revocation and the per-keyid cap ahead closes that O(verify) → O(1) gap. Step 9’s revocation state is already published externally on the signer’s origin; step 9a’s cap state is verifier-internal but is observable via traffic-pattern analysis by any sustained attacker. The spec intentionally pairs the distinct request_signature_rate_abuse error code with the SHOULD alert operators requirement (see Transport replay dedup) so cap observations surface as incident signal rather than silent oracles — a compromised-key event should be loud for the operator even if it is also legible to the attacker who caused it.
A load-bearing invariant for the cap. External traffic without the private key cannot grow the cap: the replay-cache insert happens at step 13, after crypto verify (step 10) and before body well-formedness (step 14), so any request that fails at step 10 never consumes a cap entry, and any request that fails at step 14 has already burned its nonce — a captured frame carrying a valid signature over a malformed body cannot be replayed to force amplified crypto-verify work. This is why 9a is a reader of cap state, not a writer — only the legitimate key holder (or anyone who has compromised the key, the case the cap exists to detect) can grow the set. Future edits to the checklist MUST preserve both orderings: moving the insert earlier (before step 10) would let any external party flood the cap using forged structurally-valid signatures; moving the insert later (after step 14) would reopen the malformed-body replay vector.
Step 12’s (keyid, nonce) dedup, by contrast, runs after crypto verify so the replay cache is not consumed by invalid signatures.
Composition with fallback authenticators
required_for governs the signature requirement relative to a caller’s credential path, not absolutely. A verifier typically accepts more than one authenticator (bearer, API key, mTLS, 9421) and required_for is one lever within that auth chain, not an override that trumps the others.
Terminology for the rule below: unauthenticated means the caller presents neither a valid signature nor any other credential the verifier accepts for this operation. An unrecognized bearer token or API key (one the verifier does not accept) is not a valid credential — the caller is unauthenticated and falls into the first rule.
The normative rule is:
- An unauthenticated request to a
required_foroperation MUST be rejected withrequest_signature_required. - An unsigned but otherwise authenticated request (valid bearer, API key, or mTLS identity; no
Signature-Input) to arequired_foroperation MUST NOT be rejected for missing signature. The fallback credential is what the verifier advertised as sufficient for that caller, andrequired_fordoes not retroactively invalidate the verifier’s own authenticator configuration. - A signed request enters the verifier checklist and is evaluated on its cryptographic merits, whether or not the operation is in
required_for. - A malformed signature blocks fallback regardless, per the malformed-signature rule in the checklist preamble. Broken signatures signal signer intent and MUST NOT downgrade silently to bearer.
warn_for is unchanged by this rule: it was already non-rejecting for unsigned requests and continues to surface signed-but-invalid signatures as monitoring signal during rollout.
Buyers reading required_for on a counterparty’s capability surface learn “callers presenting no credential at all will be rejected on this operation; callers presenting a bearer, API key, or mTLS credential the verifier accepts will not be rejected for missing signature.” That is not “all unsigned callers will be rejected.” A buyer that wants its own unsigned bearer calls to fail closed on a required_for operation MUST negotiate with the seller to revoke bearer credentials for that operation rather than infer the behavior from the capability block.
Why this composition and not the strict reading. The strict reading (“required_for rejects all unsigned requests regardless of fallback credentials”) has two practical problems. First, it collides with the 3.0 rollout pattern: sellers promote operations supported_for → warn_for → required_for over quarters, and most have live bearer traffic on the same operations during the transition. A strict reading would force every counterparty to migrate to signing in lockstep with the seller’s required_for flip, or break. Second, it creates an action-at-a-distance bug: a seller enabling required_for for operational monitoring purposes would inadvertently 401 every bearer-authed buyer on that operation with no warning and no remediation path short of removing the capability. The composition rule makes required_for safe to enable incrementally — its effect is scoped to the unauthenticated branch the verifier actually owns.
Content-digest and proxy compatibility
Coveringcontent-digest binds the request body bytes to the signature. For spend-committing operations, this is the whole point: the body specifies the money, and a signature that doesn’t commit to the body is not protecting the attack surface that matters. In server-to-server AdCP deployments — which is most of them — body-modifying intermediaries are rare and usually the result of a specific deliberate configuration. Default position: cover content-digest for spend-committing operations; treat transports that prevent body preservation as bugs to fix rather than constraints to accommodate.
Verifiers that genuinely cannot preserve body bytes due to legacy infrastructure MAY advertise covers_content_digest: "forbidden"; this is an opt-out for the narrow case where the infrastructure cannot be fixed. "required" is recommended for all spend-committing operations. "either" is the default — signers choose per-request, and the verifier accepts both covered and uncovered forms.
"required" is strict. When a verifier advertises covers_content_digest: "required", a signed request with a body that does not cover content-digest is a hard reject with request_signature_components_incomplete. Verifiers MUST NOT accept it as a “soft” signed-but-body-unbound request; there is no soft mode. Signers that don’t want to cover content-digest for a given call MUST route to a verifier whose policy is "either" or "forbidden", or not sign the call at all.
Transport replay dedup
Step 12 of the verifier checklist requires per-(keyid, nonce) deduplication. Unbounded sets are a memory and DoS risk.
- TTL on each entry =
(expires − now) + 60 sto match the symmetric clock-skew tolerance applied at window validation. Typical TTL ≤ 360 s (5 min + 60 s skew). - In-memory LRU keyed on
(keyid, nonce)with TTL eviction, sized to expected request rate × max signature validity. - Above ~10K req/s per signer: Redis
SETNXwithEX = remaining_validity_seconds + 60. - Distributed verifiers (multi-region): per-region replay cache is acceptable. The only attack this enables is a single replay within (expires − now + 60 s) across regions, bounded by ~6 min and only effective if the attacker controls intermediate routing.
(keyid, nonce) value as the replay key — those produce false positives that reject legitimate agent traffic.
Per-keyid cap. To prevent an abusive or compromised signer from exhausting verifier memory with unique nonces, verifiers MUST enforce a per-keyid entry cap on the replay cache. Recommended ceiling: 1,000,000 entries per keyid. On cap exceeded, verifiers MUST reject new signatures from that keyid with request_signature_rate_abuse — NOT silently evict — and SHOULD alert operators, because hitting the cap indicates either a compromised key or a grossly misconfigured signer. Silent eviction is the dangerous mode: it creates replay windows exactly when the verifier is under attack. The per-keyid cap is distinct from the total cache ceiling; a verifier may legitimately hit its total ceiling via many well-behaved signers, but per-keyid exhaustion is unambiguously an attack signal. The cap check is step 9a of the verifier checklist — evaluated before crypto verify so an abusive signer cannot force amplified Ed25519/ECDSA work on the verifier.
Single-process vs. distributed enforcement. In a single-process verifier, step 9a (read) and step 13 (insert) are sequential in one execution and the cap is exact. In a distributed verifier sharing a Redis-backed replay cache, step 9a is a cheap fast-path amplification guard but is not authoritative: two verifiers can both observe size == cap − 1, both pass 9a, both pass steps 10–12, and both insert at step 13. To avoid cap drift, the step 13 insert SHOULD be atomic with a cap check (e.g., a Lua script or SETNX pattern that returns an over-cap sentinel) — step 9a remains the cheap amplification guard, step 13 is the authoritative enforcement point. A verifier whose atomic insert returns over-cap MUST reject the request with request_signature_rate_abuse rather than let it succeed; a cap that is advisory at step 13 is not a cap.
Transport revocation
Operators SHOULD serve a single combined revocation list at the brand.json origin covering governance, request-signing, and any other agent signing keys published under theiragents[] entries. Format and signing semantics match the governance revocation list (see Revocation above). For request-signing keys:
revoked_kidsinvalidates every request ever signed under thatkid(before or after the revocation timestamp).revoked_jtisis not used (request signatures don’t have ajti; nonce uniqueness is per-key).
next_update (floor 1 min, ceiling 30 min). The fetch-failure safe-default applies with grace = 4× the previous polling interval: verifiers that have not refreshed within next_update + grace MUST reject new request-signed mutations with request_signature_revocation_stale until the list is refreshed.
Transport capability advertisement
Verifiers advertise signing support and per-call requirements via therequest_signing block on get_adcp_capabilities:
supported: when true, the verifier validates signatures when present. When false or absent, signatures are ignored.covers_content_digest: one of"required","forbidden", or"either"(default)."required": signers MUST covercontent-digest; unsigned-body signatures are rejected."forbidden": signers MUST NOT covercontent-digest; body-bound signatures are rejected."either": signer chooses; verifier accepts both.required_for: AdCP protocol operation names (not transport-specific) for which unsigned requests that present no other valid credential are rejected withrequest_signature_required. Empty in 3.0 by default. Signers MUST sign any listed operation. Composition with bearer, API key, or mTLS fallbacks is governed by Composition with fallback authenticators — in particular, unsigned requests that present a valid fallback credential are accepted, and sellers that intend signing to be unconditional MUST configure their fallback authenticators to reject other credential types for the operation.warn_for: operations for which the verifier verifies signatures when present, logs failures in monitoring, but does NOT reject. Used as a shadow-mode bridge fromsupported_fortorequired_for. Enables per-counterparty pilots where the seller watches real-traffic failure rates before enforcing. Precedence:required_for > warn_for > supported_for. Signers SHOULD sign operations inwarn_for; verifiers MUST NOT reject unsigned or failed-verify requests to these operations.supported_for: operations for which signatures are verified when present but not required. Signers SHOULD sign these. Typically a superset ofrequired_forandwarn_for.
- Announce signing readiness: add the operation to
supported_for. Counterparties can begin signing but nothing changes if they don’t. - Promote to shadow mode: move the operation to
warn_for. The verifier logs verification failures; traffic is unaffected. Operators monitor the failure rate and debug. - Enforce: when the failure rate drops below the operator’s threshold, move to
required_for. Unsigned or invalid-signature requests to that operation are now rejected.
required_for: [] and populate it selectively. warn_for is the recommended pre-production stop before flipping to enforce. In 4.0 the protocol normatively requires required_for to include all spend-committing operations the verifier supports, and covers_content_digest: "required" is recommended for those operations.
Transport error taxonomy
Stable codes returned inWWW-Authenticate: Signature error="<code>" on 401, and surfaced by SDK verifiers as typed errors. Naming pattern matches the governance taxonomy so SDK error handling is symmetric.
| Failure | Retry? | Code |
|---|---|---|
Unsigned request where signing is required — either (a) operation is in required_for, or (b) request payload carries a field that triggers signing regardless of required_for membership (e.g., push_notification_config.authentication or accounts[].notification_configs[].authentication on a signing-capable seller — see Webhook callbacks) | No | request_signature_required |
Request @target-uri is syntactically malformed (e.g., empty authority, bare IPv6, IPv6 zone identifier, raw non-ASCII host), OR canonicalized @authority does not byte-match the authority component of the canonical @target-uri (cross-vhost replay) | No | request_target_uri_malformed |
Signature or Signature-Input header present but malformed | No | request_signature_header_malformed |
Required sig-param absent (created, expires, nonce, keyid, alg, or tag) | No | request_signature_params_incomplete |
tag not adcp/request-signing/v1 | No | request_signature_tag_invalid |
alg not in allowlist | No | request_signature_alg_not_allowed |
Signature window invalid (expires ≤ created, skew, expired, > 5 min validity) | No | request_signature_window_invalid |
| Required covered components missing | No | request_signature_components_incomplete |
Covered components include content-digest when capability is "forbidden" | No | request_signature_components_unexpected |
keyid not in signer JWKS after one refetch | No | request_signature_key_unknown |
JWK key_ops lacks verify, use ≠ sig, or adcp_use ≠ request-signing | No | request_signature_key_purpose_invalid |
keyid ∈ revoked_kids | No | request_signature_key_revoked |
| Revocation list not refreshed within grace | No (block new) | request_signature_revocation_stale |
| Cryptographic verification failed | No | request_signature_invalid |
content-digest mismatch with recomputed digest | No | request_signature_digest_mismatch |
| Body contains duplicate object keys (parser-differential vector) | No | request_body_malformed |
| Nonce already seen within window | No | request_signature_replayed |
| Per-keyid replay cache exceeded its entry cap | No (block new) | request_signature_rate_abuse |
| JWKS fetch transient failure | Yes (with backoff) | request_signature_jwks_unavailable |
| JWKS fetch fails SSRF validation | No | request_signature_jwks_untrusted |
WWW-Authenticate format. AdCP does NOT define a realm value for request-signing challenges. Verifiers MUST emit WWW-Authenticate: Signature error="<code>" with no realm parameter and no other parameters. Clients parsing the header MUST tolerate other parameters (RFC 7235 permits implementations to include extras) but SHOULD NOT depend on them.
Webhook callbacks
Push-notification webhooks (POSTs to thepush_notification_config.url a buyer registers), account-level webhooks (POSTs to accounts[].notification_configs[].url), and similar asynchronous seller-initiated callbacks are signed under a symmetric variant of this profile. Role direction is inverted relative to request signing: the seller signs outbound, the buyer verifies. 9421 webhook signing is baseline-required for any 3.0 seller that emits webhooks, with a deprecated HMAC fallback described in Webhook Security.
Baseline with programmatic advertisement. 9421 webhook signing is baseline-required for any seller that emits webhooks — the default is signed, not a negotiated option. The webhook_signing capability block on get_adcp_capabilities exists so buyers can detect a non-signing seller at onboarding rather than discovering it by traffic inspection (which is how the asymmetry with request_signing manifested before this block was restored). A seller whose capability surface advertises mutating-webhook emission elsewhere (e.g., media_buy.reporting_delivery_methods includes webhook, media_buy.content_standards.supports_webhook_delivery: true, or wholesale_feed_webhooks.supported: true) MUST include this block with supported: true. A seller that emits no webhooks MAY omit the block entirely; supported: false is reserved for the unsafe posture of emitting unsigned webhooks and MUST NOT be used to signal absence-of-webhooks. Buyers that integrate with a seller whose surface advertises mutating-webhook emission while the webhook_signing block advertises supported: false or is omitted MUST fail onboarding with a user-actionable error — a seller that emits but does not sign webhooks is unsafe to integrate with for any mutating-webhook use case.
supported: MUST betruewhen the seller advertises mutating-webhook emission elsewhere in its capability surface. Buyers reject onboarding whensupported: falseor the block is missing and the seller’s surface advertises webhook emission. Sellers that emit no webhooks SHOULD omit the entire block.profile: MUST be exactlyadcp/webhook-signing/v1for this profile version. Future profile versions bump the string.algorithms: subset of["ed25519", "ecdsa-p256-sha256"]— the algorithm set this seller will sign with. Matches the webhook-signing verifier allowlist (see step 4 of the verifier checklist, reused for webhooks via the substitutions noted above). Buyers MUST reject onboarding with a user-actionable error if the advertisedalgorithmsarray contains any value outside this set; an out-of-set algorithm indicates a misconfigured or non-conforming seller and silent acceptance would defeat the allowlist.legacy_hmac_fallback:trueiff the seller supports the legacy HMAC-SHA256 scheme when the buyer populatespush_notification_config.authentication.credentialsoraccounts[].notification_configs[].authentication.credentials.falseis the recommended posture in 3.x.
push_notification_config.authentication.credentials or accounts[].notification_configs[].authentication.credentials; otherwise the seller signs with the 9421 webhook profile. Sellers MAY decline to support the legacy scheme — see the legacy_hmac_fallback flag above.
Mode selection is a switch, not both. The presence of push_notification_config.authentication or accounts[].notification_configs[].authentication selects exactly one signing mode for every webhook delivered to that URL: authentication present → legacy HMAC-SHA256 (or Bearer); authentication absent → 9421. Sellers MUST NOT sign the same webhook both ways. Buyers MUST NOT attempt “try 9421 first, fall back to HMAC” verification — that pattern creates downgrade oracle behavior and accepts signatures the buyer did not ask for. Verifiers key the verification path strictly off whether the receiver has a configured HMAC secret for the webhook registration.
Key publication. Webhook-signing keys are published by the seller in its own brand.json agents[] entry at the signing agent’s operator domain, at the jwks_uri member of that entry — the same publication pattern as any other AdCP agent key. An agent that signs both outgoing requests and outgoing webhooks publishes one JWKS with two distinct JWKs differentiated by adcp_use. Each webhook-signing JWK MUST declare:
| Member | Value |
|---|---|
use | "sig" |
key_ops | ["verify"] |
adcp_use | "webhook-signing" |
kid | distinct within the JWKS; MUST NOT collide with any other kid regardless of adcp_use |
alg | "EdDSA" or "ES256" |
adcp_use is not exactly "webhook-signing" with webhook_signature_key_purpose_invalid.
Trust anchor and blast radius. The trust anchor for webhook authenticity is the signer’s brand.json origin — the HTTPS origin that hosts the brand.json declaring the signing agent’s agents[] entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of /.well-known/brand.json or the jwks_uri) compromises every webhook that buyer accepts from that signer until the operator publishes a revoked_kids entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent’s jwks_uri URL learned at integration onboarding and alarm on changes to the URL itself (not just on kid rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. kid collisions across adcp_use values within the same JWKS are forbidden specifically so a request-signing-key compromise cannot be repurposed as a webhook-signing capability.
Covered components are identical to request signing: @method, @target-uri, @authority, content-type, and content-digest. content-digest is REQUIRED on webhook callbacks — the body carries the event, and webhook receivers are buyer-controlled endpoints where body preservation is the buyer’s own infrastructure problem. There is no covers_content_digest: "forbidden" opt-out for webhooks; transports that cannot preserve webhook body bytes MUST be fixed.
Signature parameters are identical to request signing with one override:
| Parameter | Notes |
|---|---|
created, expires, nonce, keyid, alg | Same semantics as request signing parameters. |
tag | MUST be exactly adcp/webhook-signing/v1. Verifiers MUST reject adcp/request-signing/v1 on a webhook route with webhook_signature_tag_invalid. The distinct tag prevents a request signature from being replayed as a webhook signature and vice versa. |
- Seller agent URL
A→ fetch/.well-known/brand.jsonat the operator domain ofAwith SSRF validation per Webhook URL validation. brand.json resolution follows one redirect (authoritative_locationorhouseredirect variant) and stops. - In the fetched brand.json, find the
agents[]entry whoseurlbyte-for-byte matchesA. - Fetch that entry’s
jwks_uri(or default to/.well-known/jwks.jsonat the origin ofA) with SSRF validation. JWKS cache TTL bounded above by the revocation-list polling interval (floor 1 min, ceiling 30 min). Long-running task flows cross JWKS rotations; verifiers MUST NOT pin a single JWKS snapshot for the lifetime of a task. - Resolve
keyidon the incomingSignature-Inputto a JWK in the fetched set. Onkidmiss, refetch once (subject to the 30-second cooldown between refetches) before rejecting withwebhook_signature_key_unknown. The refetch-on-miss path is the load-bearing mechanism for handling mid-task key rotation — clients that skip it will reject legitimate post-rotation deliveries.
task_id, operation_id, etc.) or from adagents.json entries — those are publisher authorization, not signer identity. Identity is established solely via the signature → JWKS → seller agents[] entry chain.
Downgrade and injection resistance. The buyer’s webhook-signing preference is communicated by the presence or absence of push_notification_config.authentication or accounts[].notification_configs[].authentication on the inbound request that registers the webhook. In 3.0 that inbound request is frequently bearer-authenticated rather than 9421-signed, so an on-path mutator (misconfigured proxy, compromised intermediary) could strip or inject the authentication block silently. The following rules contain the blast radius:
- Sellers MUST log every request that arrives with a non-empty
authenticationblock. Ops alarms on unexpected HMAC selection protect the buyer side when the buyer thought it was getting 9421. - Sellers that support request signing MUST require the inbound request to be 9421-signed (per the request verifier checklist) when
authenticationis present onpush_notification_config.authenticationor anyaccounts[].notification_configs[].authentication, rejecting withrequest_signature_required(the same code used forrequired_foroperations — see Transport error taxonomy). When a signed request cryptographically commits to the body, theauthenticationblock cannot be injected or stripped without also invalidating the signature. Sellers that do not support request signing at all have no way to enforce this rule and fall back to the log-and-alarm posture in the preceding bullet — 3.0 migration note, not an exemption: the request-signing migration timeline makes request signing required for spend-committing operations in 4.0, at which point no seller is unsigned-only. - Buyers MUST reject with
webhook_mode_mismatchand alarm, not silently downgrade, when they receive a 9421-signed webhook after registering withauthentication.credentials, or when they receive HMAC-signed webhooks after registering withoutauthentication. Rejection is the safety property; alarming is the telemetry — a buyer that alarms but accepts the payload has already handed authority to the mismatched signing scheme. The rejection surfaces as HTTP401with the stable error code so sender-side retry logic can route it to incident response rather than replaying identically. - Buyers SHOULD negotiate HMAC-mode out-of-band at onboarding when interoperating with sellers that have not yet implemented 9421. Durable per-counterparty mode selection in operator records is not MITM-mutable the way a per-request field is.
tag value (adcp/webhook-signing/v1 instead of adcp/request-signing/v1) and the direction-of-trust resolution (seller’s brand.json agents[] entry instead of the buyer’s). Step 14 (body well-formedness) is identical across the two profiles; only the error-code prefix differs (webhook_body_malformed vs request_body_malformed). Implementations SHOULD share verifier code between the two profiles, branch on the two parameter substitutions, and configure the profile-specific error codes — NOT fork the implementation. Error codes are prefixed webhook_* — most carry the webhook_signature_* infix, plus structural codes without it (currently webhook_target_uri_malformed, webhook_mode_mismatch, webhook_body_malformed) — so caller-side error handling distinguishes the two profiles.
-
Parse
Signature-InputandSignatureheaders per RFC 9421 §4. Reject if malformed (webhook_signature_header_malformed). IfSignatureorSignature-Inputis present without the other, reject with the same code — a bound pair, not a guessable one. -
Reject if any of
created,expires,nonce,keyid,alg, ortagis absent from theSignature-Inputparameters (webhook_signature_params_incomplete). -
Reject if
tagis not exactlyadcp/webhook-signing/v1(webhook_signature_tag_invalid). Byte-for-byte match; no case-folding. -
Reject if
algis not in the allowlist (ed25519,ecdsa-p256-sha256). Library defaults MUST NOT be relied upon (webhook_signature_alg_not_allowed). -
Reject if
expires ≤ created,created > now + 60 s,expires < now − 60 s, orexpires − created > 300 s(webhook_signature_window_invalid). -
Reject if covered components do not include ALL of:
@method,@target-uri,@authority,content-type,content-digest(webhook_signature_components_incomplete).content-digestis REQUIRED; there is no policy branch. -
Resolve
keyidto a JWK via the JWKS discovery steps above. Onkidmiss, refetch once (30-second cooldown between refetches) before rejecting (webhook_signature_key_unknown). Reject ifkeyidcannot be resolved to a specificagents[]entry in the signer’s brand.json. -
Verify the JWK’s
useis"sig",key_opsincludes"verify", andadcp_useequals"webhook-signing". Reject on any mismatch, including absentadcp_use(webhook_signature_key_purpose_invalid). -
Check the Transport revocation list (reused across signing purposes). Reject if
keyid ∈ revoked_kids(webhook_signature_key_revoked). Reject withwebhook_signature_revocation_staleif the verifier has not refreshed within grace. 9a. Per-keyid cap check. Check the webhook replay-cache cap. Reject withwebhook_signature_rate_abuseif exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing. -
Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying
@target-uricanonicalization AND@authorityderivation per the request-signing profile. The@authorityrule is load-bearing for webhook security: verifiers MUST derive@authorityfrom the HTTP/2+:authoritypseudo-header when present, otherwise from the as-received HTTP/1.1Hostheader — NOT from reverse-proxy routing state, load-balancer metadata, or anyHostvalue a forward proxy may have rewritten in transit. If both:authorityandHostare present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects withwebhook_target_uri_malformed. The canonicalized@authorityMUST byte-for-byte match the authority component of the canonical@target-uri; mismatch rejects withwebhook_target_uri_malformed. That byte-match against the signed@target-uri— not the choice of source header — is the only safe gate, becauseHostitself can be rewritten in transit. Implementers building from this checklist alone — without cross-referencing the profile — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated webhook and replays it to a second vhost on the same verifier pool: same cert SAN, differentHost). After canonicalization completes, verify the signature against the JWK (webhook_signature_invalidon failure). -
Recompute
content-digestfrom the received body bytes and compare (webhook_signature_digest_mismatchon mismatch). REQUIRED — no policy branch. -
Check the nonce against the replay cache. Reject if
(keyid, nonce)has been seen within the replay-cache TTL (webhook_signature_replayed). -
Only after steps 1–12 have all passed, insert
(keyid, nonce)into the replay cache with TTL =(expires − now) + 60 s. This insert MUST happen before the body-well-formedness check at step 14 so that a captured frame carrying a valid signature over a malformed body cannot be replayed to burn crypto-verify CPU on each retry — the nonce is burned on first sighting of a cryptographically-valid frame, regardless of body shape. The load-bearing cap invariant this ordering preserves is documented after step 14b. -
Body well-formedness. Verifiers MUST reject bodies containing duplicate object keys (
webhook_body_malformed). Per RFC 8259 §4, duplicate-key parse behavior is unpredictable — the signature is valid over the bytes on the wire, but two parsers can disagree on the parsed value, which is a parser-differential attack class (cf. CVE-2017-12635). This check closes the gap between the signature verifier’s view of the payload and the downstream consumer’s view. A verifier that crashes rather than returning a structuredwebhook_body_malformederror is conformant-but-suboptimal — senders receive no actionable error code. The conformance fixture for this check is theduplicate-keys-conflicting-valuesvector instatic/test-vectors/webhook-hmac-sha256.json— the 9421 profile MUST apply the same body-well-formedness rule after signature verification succeeds.webhook_body_malformedis distinct fromwebhook_signature_digest_mismatch: the signature IS valid; the body parses to ambiguous state. 14a. Strict-parse requirement. The check MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. Query libraries that happily return a value on duplicate-key input without surfacing the collision also do not satisfy this requirement, regardless of marketing as “safe” or “strict” (cf.tidwall/gjsonin Go — a query library, not a validator). Per-language strict-parse escape hatches, canonical non-exhaustive list:- Python: stdlib
json.loads(..., object_pairs_hook=...)— detect duplicates inside the hook and raise. Satisfies the check. - Node: no strict mode in
JSON.parse. Use a streaming parser (stream-json,jsonparse) with a duplicate-key event handler.secure-json-parseis NOT sufficient by default: its protections target prototype-pollution keys (__proto__,constructor), not data-key duplicates, which it still collapses last-wins. Configure it to reject data-key duplicates explicitly or layer a streaming parser underneath. - Go:
encoding/jsonhas no strict mode and does not detect duplicates. Usejson.Decodertoken-walk with an explicitmap[string]struct{}unique-key guard per object scope, ORgoccy/go-jsonwithdecoder.DisallowDuplicateKey()explicitly enabled (NOT the default). Do NOT usetidwall/gjsonfor this check — it is a query library that returns the last value on duplicate-key input without signaling the collision. - Java: Jackson
DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY(disabled by default, enable explicitly). - Ruby: stdlib
JSON.parsehas no detection hook. UseOj.load(..., mode: :strict)with theallow_nan: false/ duplicate-rejection options explicitly configured.
webhook_body_malformedrejection; logkeyid, nonce, byte length, and the specific duplicate key names only. An attacker holding a compromised signer key can otherwise force attacker-chosen bytes into defender logs at scale, burning a replay-cache slot per frame but leaving an attacker-controlled log trail for SIEM poisoning or credential exfiltration follow-on attacks. When logging duplicate key names, verifiers MUST sanitize each name with the following rules applied in order:- (a) Truncate at the first non-printable codepoint and emit
<sanitized:N>where N is the byte length of the truncation prefix. This elides position information (the placement of a non-printable within the key name would otherwise itself be an attacker channel, encodable as bit positions) while preserving the “something was wrong here” diagnostic signal. The non-printable set MUST include at minimum: C0 controls (U+0000–U+001F), DEL (U+007F), C1 controls (U+0080–U+009F, terminal control semantics in multi-byte form), bidi controls and isolates (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 — reverse rendering in terminals and SIEM UIs), line and paragraph separators (U+2028, U+2029 — render as line breaks in many log viewers, enabling row-injection), zero-width characters (U+200B–U+200D — invisible obfuscation), and the byte-order mark (U+FEFF — parser corruption). Implementations MAY extend the set to a broader Unicode non-printable classification but MUST NOT narrow it — an ASCII-only check misses bidi-override and line-separator attacks that reopen exactly the log-injection channel this rule exists to close. - (b) Truncate to at most 32 bytes at the last complete UTF-8 codepoint boundary. Realistic AdCP field names top at roughly 24 characters (
signed_authorized_agents), so 32 is a generous cap while still bounding the attacker-controlled-byte surface. Truncation MUST occur at the last complete UTF-8 codepoint boundary at or below 32 bytes so multi-byte sequences are not split mid-codepoint and invalid-UTF-8 does not land in logs (different verifiers truncating the same input to different invalid-UTF-8 tails would also break log aggregation). - (c) Cap the number of duplicate key names logged per rejection at 4, emitting
<...N more>if exceeded. Diagnostic value of knowing 4 vs 8 vs 16 colliding keys is near zero.
- Python: stdlib
idempotency_key runs after signature verification (step 13) to protect against duplicate side effects.
One signature per webhook. Verifiers MUST process exactly one Signature-Input label and ignore additional labels.
Webhook replay dedup sizing
Replay dedup for webhooks reuses the(keyid, nonce) key shape and TTL semantics from Transport replay dedup, but the buyer-side cache sees signatures from every seller the buyer integrates with — fundamentally different fan-in from the request-side case.
- Per-keyid entry cap: recommended 100,000 entries (10× lower than the request-side 1,000,000 ceiling). A seller emitting 100K unique webhooks in a 6-minute window is 275/sec sustained from a single signer — plenty of headroom for normal operations and still a strong signal of misconfiguration or key compromise.
-
Aggregate cache cap: recommended
min(aggregate_memory_budget, 10,000,000)entries across all signers. On aggregate-cap exceeded, verifiers MUST reject new signatures withwebhook_signature_rate_abuseand SHOULD alert operators — silent eviction creates replay windows precisely when the verifier is under attack. - Per-seller budget: operators SHOULD budget per-seller by integration criticality rather than equal-weighting all sellers at 100K each. A spend-committing seller’s webhook fan-in differs from a discovery-only seller’s.
-
New-keyid admission pressure (MUST track, SHOULD alert). Verifiers MUST track the rate of cache entries admitted from previously-unseen
keyids per unit time (e.g., a 5-minute rolling count of distinctkeyids inserting their first entry). A sudden spike in new-keyid admission rate is the signature of a distributed-compromise attack: an attacker holding N compromised signer keys can drive N entries per TTL window each, every key staying well within its per-keyid cap (step 9a), while collectively saturating the aggregate cache. Each key’s traffic individually looks like a low-volume legitimate signer; the aggregate shape is the signal. Verifiers SHOULD alert when new-keyid admission exceeds any of four thresholds (whichever triggers first), each closing a distinct attacker pattern:- (a) a short-window ratio threshold comparing the current admission rate against a short-horizon moving-average baseline — catches sudden spikes against a stable baseline.
- (b) a medium-window ratio threshold against a medium-horizon percentile baseline — catches multi-week ramp-up attacks, whose traffic is dominated by the baseline tail at that horizon.
- (c) a long-window ratio threshold against a long-horizon percentile baseline — catches multi-month ramp-up attacks that drift the medium-horizon anchor with them.
- (d) a proportional ceiling combining an absolute floor with a fraction of the unique-keyid count over a documented window — catches sparse-traffic verifiers whose ratio baselines are near zero, AND auto-scales to operators of any size (small verifiers get a low proportional floor; enterprise verifiers get a proportionally larger one).
threshold_tuning_overdueevent when any threshold remains at its shipped starting value more than 30 days past the verifier’s first admission; this gives the operator-tuning obligation a testable, auditable hook rather than relying on operator diligence alone. The alarm payload MUST name which clause (a, b, c, or d) tripped so operator triage can respond to the right threat shape. Alarming here catches the slow-burn distributed-compromise pattern before the aggregate cap triggers — oncewebhook_signature_rate_abusefires on the aggregate cap, the cache is already full and every legitimate signer is being rejected. Alarms SHOULD route to incident response, not to automatic revocation: the distinguishing signal between “attack” and “onboarding a batch of new sellers” is operator context, not machine-derivable, and automatic revocation on alarm creates a denial-of-service vector (any party driving legitimate new-signer onboarding can trip the alarm and cause mass revocation).
- Share a single logical replay cache across every endpoint a given signer can reach (Redis / shared dedup service — not per-process in-memory), so that a
(keyid, nonce)inserted by endpoint A is visible to endpoint B before step 12 runs; or - Include the canonical destination URL in the replay key, scoping dedup to
(keyid, canonical destination URL, nonce). The canonical form is the@target-uriafter normalisation per the request-signing profile (scheme lowercased, host IDNA-normalised, default port elided, fragment stripped).
(keyid, nonce) is replayable at each distinct endpoint URL, but because the signed @target-uri is covered by the signature, the verifier at endpoint B will reject any payload whose @target-uri was signed for endpoint A with webhook_signature_digest_mismatch (the canonical signature base fails) or webhook_signature_invalid. Option 2 is acceptable only when the signer’s canonical @target-uri is per-endpoint; a signer that signs the same payload for multiple endpoints defeats option 2 and MUST use option 1.
Per-pod or per-region in-memory replay caches without a shared tier are non-conformant for buyers that run more than one endpoint: they leave a cross-endpoint replay window bounded only by ±360 s and the attacker’s ability to route to a different pod. Operators MUST either front the webhook fleet with a shared dedup tier or document and enforce the per-endpoint URL scoping above.
All other rules from Transport replay dedup apply verbatim: in-memory LRU for single-process verifiers, Redis SETNX at high volume, atomic insert-with-cap-check at step 13 in distributed deployments.
Webhook revocation and rotation
Signers MUST publish revocations via the same combined revocation list used for request signing — see Transport revocation. A single list per operator origin covers governance-signing, request-signing, and webhook-signing keys. HMAC→9421 migration. A buyer transitioning from HMAC to 9421 MUST disable its HMAC verifier once the seller has acknowledged the cutover. Running both verifiers concurrently leaves the HMAC path exploitable for the original 5-minute replay window plus however long the buyer forgets to turn it off; “just in case” operational posture keeps the deprecated path live past the intended deprecation. Sellers SHOULD rejectauthentication blocks from a counterparty that has previously been migrated to 9421, logging the rejection. During the cutover window, buyers MAY run both verifiers but SHOULD maintain a single dedup keyspace so that the same logical event under either scheme maps to the same (sender identity, idempotency_key) tuple — see the Reliability section for dedup scope under mixed-mode delivery.
Webhook error taxonomy
Codes parallel the request-signing error taxonomy, prefixedwebhook_ so SDK error handling distinguishes the two profiles. Buyers MAY return 401 to the seller on any of these; a seller’s retry loop will replay with the same signature bytes, so every code in this table is non-retryable to the sender — signature failures, authority-mismatch, and mode-mismatch all produce identical outputs on retry — even though HTTP semantics permit retry.
| Failure | Code |
|---|---|
Signature or Signature-Input header malformed or one without the other | webhook_signature_header_malformed |
| Required sig-param absent | webhook_signature_params_incomplete |
tag not adcp/webhook-signing/v1 | webhook_signature_tag_invalid |
alg not in allowlist | webhook_signature_alg_not_allowed |
| Signature window invalid | webhook_signature_window_invalid |
Required covered components missing (including content-digest) | webhook_signature_components_incomplete |
keyid not in seller JWKS after one refetch | webhook_signature_key_unknown |
JWK adcp_use ≠ webhook-signing | webhook_signature_key_purpose_invalid |
keyid ∈ revoked_kids | webhook_signature_key_revoked |
| Revocation list not refreshed within grace | webhook_signature_revocation_stale |
| Cryptographic verification failed | webhook_signature_invalid |
content-digest mismatch | webhook_signature_digest_mismatch |
| Body contains duplicate object keys (parser-differential attack class) | webhook_body_malformed |
@authority does not match signed @target-uri authority component (cross-vhost replay) | webhook_target_uri_malformed |
| Nonce already seen within window | webhook_signature_replayed |
| Per-keyid replay cache exceeded cap | webhook_signature_rate_abuse |
| Registered auth mode does not match signature mode on received webhook | webhook_mode_mismatch |
401 response carrying WWW-Authenticate: Signature error="webhook_*" (any code defined in the taxonomy above, including webhook_signature_*, webhook_target_uri_malformed, and webhook_mode_mismatch) as a terminal failure for that specific delivery attempt: stop retrying the current event, log the failure with the error code for operator attention, and continue the normal retry queue for subsequent events. Senders SHOULD route sustained webhook_* error rates above an operator-defined threshold to incident response rather than continuing to emit them — persistent signature, authority, or mode failures indicate a key-rotation coordination problem, a misconfigured verifier, or a compromise, all of which need human action. Receivers MUST NOT silently discard these failures; surfacing them in operator logs is part of the security posture.
Editor note on future additions. The wildcard webhook_* terminal-failure classification above is an eager sweep: any new code added to the taxonomy inherits terminal-per-delivery semantics without individual review. Editors adding a new webhook_* code that SHOULD be retryable (e.g., a future transient-infrastructure signal) MUST update this paragraph to carve out the exception at the point of addition — do not rely on the pattern match to remain safe for codes not yet defined.
Webhook migration timeline
| Phase | Behavior |
|---|---|
| 3.0 GA | 9421 webhook signing is baseline for any seller that emits webhooks. Legacy HMAC-SHA256 fallback available when buyer populates push_notification_config.authentication.credentials or accounts[].notification_configs[].authentication.credentials; sellers MAY decline to support it. |
| 3.x | HMAC fallback is deprecated. Sellers SHOULD log warnings when selected. SDKs SHOULD surface a deprecation notice to buyers that still configure authentication. |
| 4.0 | authentication on push_notification_config and accounts[].notification_configs[] is removed from the schema. 9421 webhook signing is the only supported path. |
TMP cross-reference
TMP keys MUST declare a distinctadcp_use value (or omit it entirely) so verifiers reject them for request signing via step 8. Publishing TMP keys at the same jwks_uri as request-signing and webhook-signing keys is permitted and encouraged — one publication pattern, five signing systems, each kid-scoped:
- governance JWS —
adcp_use: "governance-signing" - request signing (RFC 9421) —
adcp_use: "request-signing" - webhook signing (RFC 9421) —
adcp_use: "webhook-signing" - designated-task response-payload JWS —
adcp_use: "response-signing"(see Designated-task payload-envelope response signing above) - TMP envelope — TMP’s own future
adcp_usevalue
adcp_use match on its own profile.
Trusted Match Protocol signs match-time requests with its own Ed25519 envelope. TMP’s per-request budget (sample-verify at ~5%) is too tight for full RFC 9421 verification on every call. TMP signing is out of scope for this section; this profile only constrains how TMP keys are published alongside request-signing keys on the same JWKS.
Transport migration timeline
AdCP 4.0 is the next breaking-changes accumulation window. Mandatory request signing for spend-committing operations is one of its floor requirements — the minimum security bar for AdCP 4.0 spend traffic — not the sole headline feature. Other v4.0 changes will accumulate on the roadmap.| Phase | Status | Behavior |
|---|---|---|
| 3.0 GA | Optional, capability-advertised | Verifiers MAY validate; required_for: [] by default. Signers MAY sign. Reference vectors ship; reference SDK pilots begin. |
| 3.x | Reference SDKs ship; pilots surface bugs | Conformance test vectors drive cross-SDK interop. Early adopters turn on required_for with named counterparties, incrementally. |
| 4.0 | Required for spend-committing operations | required_for MUST include create_media_buy, acquire_*, and any spend-committing operation the verifier supports. Signers MUST sign. covers_content_digest: "required" recommended for those operations. |
required_for selectively (per-counterparty pilot, then broader rollout) before 4.0 to validate end-to-end paths against real traffic — this is what makes the 4.0 transition feasible without ecosystem-wide breakage.
Request verifier reference (TypeScript)
Illustrative only. Theverify9421 and parseSignatureInput callbacks encapsulate protocol-specific canonicalization and signature verification; implementations should pin a specific RFC 9421 library that has been validated against the AdCP conformance test vectors at /compliance/latest/test-vectors/request-signing/.
Budget Validation
Validate budgets before committing:Transport Security
AdCP’s application-layer security primitives (9421 signing, JWS governance, idempotency) assume the transport does not help the attacker. A misconfigured TLS stack breaks that assumption — it downgrades a protocol designed to withstand active on-path adversaries into one that trusts every intermediary. This section is normative for every AdCP endpoint — inbound (seller and buyer API surfaces) and outbound (JWKS fetch, brand.json fetch, revocation list fetch, webhook delivery). It is deliberately prescriptive so operators do not have to reason from first principles about cipher suites at 3 a.m.TLS version policy
- TLS 1.3 is RECOMMENDED for every AdCP endpoint.
- TLS 1.2 is the minimum. Endpoints MUST reject TLS 1.1 and below at the handshake.
- Client-side verifiers (e.g., an AdCP server fetching a counterparty’s JWKS, brand.json, or revocation list) MUST refuse to negotiate below TLS 1.2. Libraries that still default to TLS 1.0 for “compatibility” MUST be configured explicitly.
- SSL 2.0, SSL 3.0, TLS 1.0, and TLS 1.1 MUST NOT be enabled — not for any endpoint, not for any legacy partner, not even on a separate port.
Cipher suites and algorithms
- TLS 1.3: use the IETF-defined suites (
TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256). All three are AEAD; no other TLS 1.3 suites exist. Do not disable any of them arbitrarily — operators who disable ChaCha20 on “speed” grounds are one client quirk away from broken mobile clients. - TLS 1.2: restrict to AEAD-only ECDHE suites. The permitted set is
ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-ECDSA-CHACHA20-POLY1305,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-RSA-CHACHA20-POLY1305. - CBC-MAC, RC4, 3DES, DES, NULL, EXPORT, anonymous DH, and static RSA key-exchange suites MUST be disabled on TLS 1.2 — their presence silently downgrades the security properties of everything built above the handshake.
- Server certificates MUST use ECDSA (P-256 or P-384) or RSA ≥ 2048 bits. RSA < 2048 MUST NOT be used.
- Endpoints MUST prefer server-side cipher ordering (OpenSSL
SSL_OP_CIPHER_SERVER_PREFERENCE, nginxssl_prefer_server_ciphers on) so a weak client cannot force a weak suite when a strong one is mutually available.
Certificate validation (outbound fetches)
Every outbound HTTPS request AdCP makes — JWKS, brand.json, revocation list, webhook callback, aggregator proxy — MUST perform full PKIX validation. The specific checks:- Trust chain MUST terminate at a public root the operator has intentionally included. No
--insecure, noverify=False, norejectUnauthorized: falseanywhere in production code paths. This is the single most common production compromise — an engineer turns off verification to work around a cert issue in staging, the flag ships. - SAN match is the authoritative identity check. The certificate MUST have a Subject Alternative Name entry matching the URL host. CN-only fallback MUST NOT be accepted; major HTTP clients still support it for legacy reasons, but AdCP verifiers MUST require SAN.
- Expiry MUST be checked against the current clock. Fetching a JWKS from a domain whose TLS cert expired last week is a governance red flag, not a compatibility problem.
- Hostname verification MUST be enabled in the library config. Several popular HTTP client libraries ship with hostname verification on by default; a surprising number have a flag that disables it. AdCP implementations MUST assert hostname verification is on, not assume it.
- OCSP stapling SHOULD be accepted when offered; OCSP must-staple on operator-controlled certificates is RECOMMENDED. Must-staple turns a missing staple into a hard failure, which closes the soft-fail-on-OCSP loophole.
- Certificate Transparency (CT) SCTs SHOULD be checked on endpoints serving regulated spend. Browsers already enforce CT; AdCP SDKs fetching governance JWKS on a regulated-category workflow SHOULD too, so a hidden mis-issued cert is detectable.
- Pinning is NOT required at the protocol layer and SHOULD be avoided for counterparty-supplied URLs (brand.json, JWKS) because it collides with legitimate operator cert rotation. Pinning to a public-CA chain (intermediate-pin) is acceptable; pinning to a specific leaf cert is discouraged.
Inbound server-side headers
includeSubDomains MUST be set unless the operator has a documented reason not to. Domains serving spend-committing AdCP endpoints SHOULD be submitted to the HSTS preload list.
Client / outbound TLS hardening
Outbound-fetch code paths (governance JWKS, brand.json, revocation list, webhook delivery, aggregator proxy) MUST:- Use a connection pool with a fixed per-host cap and a fixed overall cap. Unbounded pools are a resource-exhaustion surface.
- Cap TLS handshake time at 10 s and total request time at 30 s by default — counterparty-supplied URLs are a tarpit DoS vector otherwise.
- Pin the connection to the IP address that passed the SSRF controls — DNS re-resolution between the SSRF check and the actual connect is how TOCTOU bypasses land.
- Refuse redirects on security-sensitive fetches. JWKS, brand.json, revocation list, and webhook-callback fetches MUST NOT follow redirects; the brand.json resolution rule already says “one redirect (
authoritative_locationorhousevariant), no chains” — everywhere else, zero. - Disable session resumption across trust boundaries. Resuming a TLS session with an attacker-controlled counterparty onto a later verified counterparty (same IP via DNS rebind) is a well-known class of confusion; library defaults are usually fine, but the operator MUST audit.
TLS renegotiation and downgrade
- TLS 1.2 secure renegotiation (RFC 5746) MUST be enabled if renegotiation is supported at all. Insecure-renegotiation-tolerant stacks are a MUST-disable.
- TLS compression (CRIME) MUST be off.
- Heartbeat extension MUST be off on TLS 1.2 endpoints (Heartbleed lineage).
- 0-RTT / early-data on TLS 1.3 MUST NOT be enabled for any endpoint that accepts mutating AdCP operations. 0-RTT is replayable by design; idempotency and signature-nonce dedup are not free rescues once the request has hit application logic. Read-only discovery endpoints (
get_adcp_capabilities,list_creative_formats) MAY use 0-RTT; everything else MUST NOT.
mTLS transport
When mTLS is the authentication mechanism:- The client certificate SAN / Subject MUST match the buyer’s registered domain as declared in
adagents.jsonorbrand.json. Relying on any header field (X-Forwarded-Client-Cert,X-Client-DN, etc.) is explicitly forbidden — header fields can be injected across misconfigured proxies. - The terminating edge (load balancer, mesh sidecar) MUST forward the verified certificate identity to the AdCP server over an in-cluster channel the server can authenticate. Unauthenticated sidecar headers are a bypass — deploy mTLS end-to-end, or pin the in-cluster channel.
- Client certificates MUST be checked against a CRL or OCSP responder operated by the operator. “Issued by us” is not the same as “still valid.”
Private-network and metadata protection
This section’s transport controls do not substitute for the SSRF controls on counterparty-supplied URLs. Every outbound fetch to a counterparty URL MUST apply the SSRF rules — reject non-HTTPS, reject IPs in reserved ranges (including cloud-metadata addresses), refuse redirects, cap size and time. TLS is useless if the URL points at169.254.169.254.
What this section does NOT replace
Transport security is the floor, not the ceiling. Even a flawless TLS stack does not replace:- Application-layer body integrity (request signing and webhook callbacks) — TLS protects the wire, not the payload after a compromised intermediary.
- Governance attestation (signed governance context) — TLS does not tell the seller whether the buyer’s governance agent authorized this spend.
- Idempotency (request safety) — TLS does not prevent the sender from retrying after a network timeout.
Input Validation
Request Validation
Validate all user-provided input:SQL Injection Prevention
Always use parameterized queries:Audit Logging
Required Log Events
Log all security-relevant events:Log Retention
- Security logs: 90 days minimum (365 days recommended)
- Financial logs: 7 years (compliance requirement)
- Access logs: 30 days minimum
Security Checklist
For Publishers (AdCP Servers)
- Implement strong authentication (OAuth 2.0, API keys, or mTLS)
- Enforce agent and account isolation in all database queries
- Implement idempotency for financial operations
- Validate all input with strict schema validation
- Use TLS 1.3+ for all communications
- Verify webhook signatures cryptographically
- Log all security events immutably
For Buyer Agents (AdCP Clients)
- Store credentials in secure key management system
- Rotate credentials every 90 days
- Use HTTPS for all AdCP communications
- Validate responses from publishers
- Implement alerts for unusual spending patterns
For Orchestrators (Multi-Agent, Multi-Account)
- Store each agent’s credentials separately (encrypted)
- Enforce agent and account filtering in ALL queries
- Use row-level security in databases
- Log all operations with agent and account identity
- Implement per-agent rate limiting
Next Steps
- Security Model: See Security Model for the threat model and the five-layer defense narrative this reference implements
- Webhooks: See Webhooks for webhook security patterns
- Error Handling: See Error Handling for authentication errors
- Orchestrator Design: See Orchestrator Design for multi-tenant security