Documentation Index
Fetch the complete documentation index at: https://agenticadvertisingorg-changeset-release-main.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
Critical for Production UseAdCP handles financial commitments and potentially sensitive campaign data. Implementations managing real advertising budgets must implement the security controls outlined in this document.
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 |
Requirements:
- 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 its adagents.json-published key, and the buyer verifies against the seller’s published JWKS. 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 populating push_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 JavaScript JSON.stringify, Go encoding/json json.Marshal, Ruby JSON.generate, and Java Jackson writeValueAsString produce compact output by default; HTTP clients that wrap them (axios, Go net/http with a json.Marshal-ed body, Ruby Net::HTTP with JSON.generate, Java OkHttp with Jackson) inherit those defaults. In Python, httpx serializes with compact separators, but stdlib json.dumps defaults to ", " / ": " and HTTP clients that hand their payload to json.dumps without a separators kwarg (requests(json=...), aiohttp) emit spaced bodies — signers on those paths MUST pass separators=(",", ":") 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, Python json.dumps(1.0) → 1.0, Go json.Marshal(1.0) → 1; floats like 0.1 and 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 read roles=["_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 is duplicate-keys-conflicting-values in static/test-vectors/webhook-hmac-sha256.json, with expected_verifier_action: "reject-malformed". Signer-side conformance fixtures live in the same file under signer_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), and signer-upstream-duplicate-key-three-deep (verifies the walker does not halt at a shallow fixed depth). A positive-case fixture signer-upstream-clean-input lives under signer_side.positive_vectors so 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 be duplicate_key_input exactly — case-sensitive, no prefix or suffix — so that multi-SDK integrations can write if (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(), FastAPI Request.body(), aiohttp Request.read(), Go io.ReadAll(r.Body) before json.Unmarshal). The raw-capture hook MUST run before any JSON-parse middleware on the same route; a globally-mounted express.json() or FastAPI BaseModel body 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 the X-ADCP-Timestamp header. 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| > 300 seconds
- Minimum secret length: 32 bytes
- Header format:
X-ADCP-Signature: sha256=<hex digest> and X-ADCP-Timestamp: <unix seconds>. Any body-level signature field is a convenience copy and MUST NOT be trusted over the headers.
Verification order (legacy scheme):
- Reject if
X-ADCP-Signature or X-ADCP-Timestamp header is missing
- Reject if timestamp is non-numeric
- Reject if timestamp is outside the 5-minute window
- Compute and compare HMAC
Secret rotation (legacy scheme):
- 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 includes push_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 that receive high-volume event families, including wholesale feed webhooks, 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 activation challenge or equivalent proof-of-control before treating those subscribers as active.
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 includes 169.254.169.254 used 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.
- 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_location redirects 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:
Authentication Best Practices
Credential Storage
// Use secure key management systems
// Never commit credentials to version control
// Use environment variables or secret managers
// Example: Secure credential retrieval
async function getCredentials(agentId) {
// Retrieve from secure storage (AWS KMS, Vault, etc.)
const encrypted = await secretManager.get(`agent/${agentId}/apiKey`);
return decrypt(encrypted);
}
Token Expiration
Use short-lived tokens for high-risk operations:
const TOKEN_LIFETIMES = {
discovery: 3600, // 1 hour for read operations
financial: 900, // 15 minutes for financial operations
refresh: 86400 // 24 hours for refresh tokens
};
function validateToken(token, operationType) {
const decoded = jwt.verify(token, secret);
const maxAge = TOKEN_LIFETIMES[operationType] || TOKEN_LIFETIMES.discovery;
if (Date.now() - decoded.iat > maxAge * 1000) {
throw new Error('Token expired for this operation type');
}
return decoded;
}
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; the account 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.
See Accounts & Security — Data Isolation for the billing-relationship model these rules enforce, and the glossary for the formal definitions of Account and Agent.
The two-step pattern
Every request carries an explicit account (via account_id for explicit-account models, or the {brand, operator} natural key for implicit models). Correct isolation is two checks, performed in order:
- Auth precheck — the request’s
account MUST 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_id as the primary key constraint. Not by the whole authorized set — only by the specific account this request is acting on.
// Two-step: precheck request account is authorized, then scope the query to it.
// authorizedAccountIds is a Set<string> populated once at auth-time, not an Array.
// Set.has() is O(1); Array.includes() is O(n) and scans element-by-element, which
// on large authorized-account sets introduces a timing difference between early
// and late matches that a caller can probe across requests.
async function getMediaBuy(mediaBuyId, requestAccountId, authAgent) {
// Step 1: auth precheck
if (!authAgent.authorizedAccountIds.has(requestAccountId)) {
// Generic error - don't reveal whether the account exists
throw new NotFoundError("Media buy not found");
}
// Step 2: resource query scoped to the specific account
const mediaBuy = await db.mediaBuys.findOne({
id: mediaBuyId,
account_id: requestAccountId // Primary filter
});
if (!mediaBuy) {
// Generic error - same shape as the precheck failure
throw new NotFoundError("Media buy not found");
}
return mediaBuy;
}
Filtering by the whole authorized set on a by-ID lookup is a regression: a 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 by account_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:
-- PostgreSQL example
-- app.current_account is set by the auth layer AFTER the precheck above succeeds
CREATE POLICY account_isolation ON media_buys
USING (account_id = current_setting('app.current_account')::uuid);
ALTER TABLE media_buys ENABLE ROW LEVEL SECURITY;
For list endpoints (get_media_buys without an explicit account filter), RLS scopes to the agent’s authorized set via a session variable populated at auth time:
CREATE POLICY account_isolation_list ON media_buys
FOR SELECT
USING (account_id = ANY(current_setting('app.authorized_accounts')::uuid[]));
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 call create_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.
This threat is distinct from ordinary prompt injection: ordinary injection exfiltrates data or triggers unauthorized tool calls within one principal’s authority. Cross-principal confusion uses principal X’s untrusted text to reach principal Y’s authority without the attacker ever holding Y’s credentials. The server-side Layer 2 controls above detect the attempt only if principal Y’s account isn’t already in the buyer agent’s authorized set — when it is (the whole point of agency and multi-seller agents), the server sees a legitimate-looking call.
The protocol cannot force this discipline on the client agent. The test for it is operational: every LLM-powered AdCP buyer MUST be able to describe, in writing, which principals can appear together in the same planning context and what gates a cross-principal tool call.
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.
All timestamp fields in AdCP requests, responses, and webhook payloads MUST be ISO 8601 with an explicit timezone offset.
✅ 2026-04-19T10:00:00Z // UTC, recommended
✅ 2026-04-19T10:00:00-04:00 // explicit offset
❌ 2026-04-19T10:00:00 // no offset — ambiguous
❌ 2026-04-19 10:00:00 // not ISO 8601
Implementations MUST reject ambiguous (“naïve”) timestamps with 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.
A daypart with no declared semantics is ambiguous and MUST be rejected with 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_key and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with INVALID_REQUEST; sellers MAY accept the omission for the 3.1.x maintenance window.
- 3.2.0 — sellers MUST reject reads that omit
idempotency_key with INVALID_REQUEST. The grace window closes at the 3.2 cut.
This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn’t include the field uniformly — migrate over a release window rather than at the 3.1 cut. Buyer SDKs (@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 returns INVALID_REQUEST without 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: completed or status: submitted for 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 (with replayed: 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
submitted result containing task_id. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached submitted response, NOT the current terminal state. The buyer uses the returned task_id to observe current state via tasks/get or 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_packages on create_media_buy; per-record status arrays on sync_creatives / sync_accounts; resource snapshots on acquire_rights / activate_signal), replay MUST return the originally-cached payload regardless of intervening mutations to the resource. A media buy that was created with status: pending_creatives, then mutated to canceled via update_media_buy, replays as status: 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.
This preserves the byte-stable cache property uniformly and keeps the idempotency layer decoupled from resource lifecycle — sellers don’t need to update cache entries when task or resource state changes. The alternative (“refresh state fields on replay”) would force every seller to thread the resource state machine through the idempotency cache, multiply the number of valid cache contents for a given key (a single key’s replay would no longer be deterministic across calls), and break the canonical-replay invariant the rest of these rules build on. Sellers MUST NOT implement a hybrid where some state-tracking fields refresh on replay and others do not — partial refresh is the worst of both options and is non-conformant.
-
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_key AND an equivalent canonical-form payload (see “Payload equivalence” below) MUST return the stored inner response without re-executing side effects. The seller injects replayed: true onto the outgoing protocol envelope at response time — replayed is 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 (new timestamp, rotated governance_context, etc.). Transport-specific note for MCP: MCP tool responses do not have a separate envelope slot; servers MAY expose replayed inside 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_seconds elapses the seller MAY evict the cache entry. A request arriving after eviction with a key the seller has seen SHOULD be rejected with IDEMPOTENCY_EXPIRED rather 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 JWS exp elsewhere 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 declared replay_ttl_seconds is 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 (plain Map, single-process LRU without a backing tier) are non-conformant whenever replay_ttl_seconds exceeds 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 same idempotency_key under 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 a replay_ttl_seconds higher 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 declare replay_ttl_seconds no 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_seconds on get_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 return RATE_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 to replay_ttl_seconds at the 3600 s floor. The natural bound is inserts_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 polling get_products(brief) + list_creatives + list_accounts across 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_signal from 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.
Operators with steady low-volume traffic MAY tighten below these starting values; operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise rather than accept silent rejection of legitimate traffic. The split-budget shape (separate read and write counters) MUST be implemented from 3.1 onward even when operators tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents. The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when RATE_LIMITED fires; 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_LIMITED behavior 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 the RATE_LIMITED + retry_after response, 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_LIMITED rejections MUST populate retry_after (seconds) per the error handling taxonomy and MUST NOT be cached as idempotency responses (rule 3: only successful responses are cached). Sellers SHOULD enforce retry_after as a cheap rejection floor — a buyer retrying before retry_after elapses 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_FLIGHT immediately, with error.details.retry_after (seconds, integer) populated based on the first request’s elapsed time and expected completion. Buyers MUST retry with the same idempotency_key after the hint elapses — a buyer that mints a fresh key on IDEMPOTENCY_IN_FLIGHT turns a safe retry into the exact double-execution race this rule prevents.
A second request with the same key AND a different canonical payload during the in-flight window MUST return IDEMPOTENCY_CONFLICT (rule 5), not IDEMPOTENCY_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 (returning IDEMPOTENCY_IN_FLIGHT on 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 returns IDEMPOTENCY_IN_FLIGHT for the same key, locking the buyer out of any safe retry path.
Sellers using reject-and-redirect MUST set error.details.retry_after to a value no greater than replay_ttl_seconds (declared in capabilities.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 hits IDEMPOTENCY_EXPIRED on retry. Sellers SHOULD also declare capabilities.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.
-
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 on check_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.” The parallel_dispatch_runner test-kit lists rule-10 conformance under reviewer_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-generated downstream_request_id it 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 by downstream_request_id to 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 of downstream_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 the downstream_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 same idempotency_key per 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_key as the downstream’s own idempotency key — typically HMAC(K_provider, idempotency_key) where K_provider is 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 raw idempotency_key to 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.
Sellers MUST document which pattern applies to which downstream in their operational runbook. Sellers MUST NOT use a third pattern of “best-effort dedup on downstream response inspection” — comparing the downstream’s response payload to a cached fingerprint to decide whether the call already happened — because the downstream’s response shape changes across versions and the fingerprint is a synchronization bug waiting to happen. A claim row OR a threaded key. Not pattern-match-on-response.
Sellers MUST NOT include the buyer’s 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_FLIGHT or 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.
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 itself
context — buyer-opaque echo data (trace IDs, correlation IDs) changes on retry by design
governance_context — on the envelope; may be a refreshed signed token on retry
push_notification_config.authentication.credentials — may be a rotated bearer token. The URL and scheme remain in the hash; only the credential value is excluded.
Everything else in the request body — including 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)).
AdCP SDK middleware ships JCS canonicalization so sellers don’t roll their own. Rolling your own canonical form is a common source of “works on my machine” idempotency bugs — JCS is precisely specified to avoid that.
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_key is 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 with unexpected_keyword_argument (FastMCP/Pydantic strict signatures) are non-conformant.
context_id, context, push_notification_config, governance_context MUST 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.
This is the server-side counterpart to the 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) raises unexpected_keyword_argument when the buyer sends idempotency_key inside the same params object. Fix: declare idempotency_key: str | None = None (and the other envelope fields) as accept-and-ignore optional parameters, or use a **kwargs catch-all and discard unknown keys. Pydantic-on-input uses Extra.allow or model_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: false injected by the codegen tool — verify the generated input schema mirrors the spec’s additionalProperties: true default; some generators flip the default during model emission.
The wire-level invariant is: a buyer SDK MUST be able to send the same envelope-field set to every AdCP tool on every seller, and any seller that rejects on envelope fields breaks the cross-seller portability the protocol promises. This rule is normative for 3.1+; pre-existing wrappers that reject envelope fields are non-conformant at the next maintenance bump.
Reference: this rule generalizes the per-validator pattern already established for response-side validators in 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-level replayed boolean on responses to any request that resolved via the idempotency cache:
{
"status": "completed",
"replayed": true,
"timestamp": "2026-04-18T14:35:00Z",
"payload": {
"media_buy_id": "mb_01HW7J8K9P0Q1R2S3T4U5V6W7X"
}
}
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
replayed to 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
replayed before treating the response as a new event.
- Billing reconciliation — “we processed N buys this month” counts
replayed: false only.
- 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_creatives on a replayed create_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-readable message
- MUST NOT include the cached response, the original payload, a canonical-form diff, or any fingerprint derived from them. A
field json-pointer hint seems harmless but reveals schema shape (e.g., /packages/0/budget tells 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.
{
"errors": [
{
"code": "IDEMPOTENCY_CONFLICT",
"message": "idempotency_key was used with a different payload within the replay window. Either resend the exact original payload (to return the cached response) or generate a fresh UUID v4 to submit this new payload.",
"recovery": "correctable"
}
],
"context": { "correlation_id": "..." }
}
Leaking cached state turns key-reuse into a read oracle. An attacker who guesses or steals a victim’s key could otherwise probe it to infer payload structure. The error body exposes only the code.
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_message with a fresh idempotency_key is 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_FOUND or IDEMPOTENCY_EXPIRED rather than reconstruct. Buyers retrying far past a session timeout should expect this.
Buyer obligations
Buyers MUST generate a unique idempotency_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_accounts at intervals; a buyer agent reading get_media_buys to fetch fresh state after a mutation; any “give me current state at time T” call. Buyers MUST mint a fresh idempotency_key per call. Reusing the prior poll’s key would replay the cached snapshot (up to replay_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.
When in doubt, ask whether the buyer’s intent is “give me the same answer as before” (network retry — reuse the key) or “give me the current answer” (polling / state re-read — mint a new key) or “do this new thing” (agent re-plan — mint a new key). Agentic clients that loop through an LLM to build the request SHOULD freeze and cache the serialized bytes alongside the key on first send for the network-retry case, so retries send the identical payload even if the planner would produce something slightly different on re-execution.
Bootstrap carve-out — 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 implicit-accounts 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.
import { canonicalize } from "@truestamp/canonify"; // RFC 8785 JCS
import { createHash } from "node:crypto";
const EXCLUDED_FROM_HASH = new Set([
"idempotency_key",
"context",
"governance_context",
]);
function payloadHash(request) {
const filtered = Object.fromEntries(
Object.entries(request).filter(([k]) => !EXCLUDED_FROM_HASH.has(k)),
);
// If push_notification_config.authentication.credentials rotates, exclude it too
if (filtered.push_notification_config?.authentication) {
const { credentials, ...auth } = filtered.push_notification_config.authentication;
filtered.push_notification_config = {
...filtered.push_notification_config,
authentication: auth,
};
}
return createHash("sha256").update(canonicalize(filtered)).digest("hex");
}
async function createMediaBuy(request, envelope) {
if (!request.idempotency_key) {
throw new InvalidRequestError("idempotency_key is required");
}
const requestHash = payloadHash(request);
const existing = await db.findByIdempotencyKey({
agent_id: currentAgent.id,
account_id: request.account.account_id,
idempotency_key: request.idempotency_key,
});
if (existing) {
if (existing.expires_at < new Date()) {
throw new IdempotencyExpiredError("idempotency_key is past replay window");
}
if (existing.request_hash !== requestHash) {
throw new IdempotencyConflictError("idempotency_key reused with a different payload");
}
// Return the stored INNER payload; replayed: true is injected by the envelope layer
envelope.replayed = true;
return existing.response;
}
return db.transaction(async (tx) => {
const response = await processMediaBuy(tx, request);
// Cache ONLY on success, and cache only the inner response payload
await tx.idempotencyKeys.insert({
agent_id: currentAgent.id,
account_id: request.account.account_id,
key: request.idempotency_key,
request_hash: requestHash,
response,
expires_at: new Date(Date.now() + TTL_SECONDS * 1000),
});
envelope.replayed = false;
return response;
});
}
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
jti and check_id for 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.
The same string is also the primary correlation key for the governance lifecycle. The governance agent decodes its own token to look up internal state (buyer correlation IDs, policy decision log, etc.) — sellers and buyers never need to parse the payload.
Scope and dependencies
- In scope (3.0): buy-side governance. The
governance_context token 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 via conditions responses 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).
Dependency on Transport Signing (#2307): the anti-spoof property of this profile depends on sellers being able to establish the buyer domain independently of the token’s 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 to governance_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 reject none, HS*, and any RS* 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 exactly adcp-gov+jws (byte-for-byte match; verifiers MUST NOT normalize or strip the +jws structured 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 any crit-listed claim is present. Per RFC 7515 §4.1.11, crit is an array of header/claim names that MUST be understood by the verifier. Verifiers MUST reject the token if any name in crit is not recognized. Governance agents MUST list in crit any claim whose omission or misinterpretation would change authorization semantics (e.g., a future budget_cap claim). This prevents silent downgrade attacks when the profile adds claims in later versions.
Claims
| 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. |
Unknown-claim handling: verifiers MUST ignore claims whose names they do not recognize unless those claim names appear in the token’s 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
keyid resolving to a buyer-declared public key in the buyer’s adagents-style agent registry.
Sellers MUST NOT derive the buyer identity from an unauthenticated field in the request (including the token’s 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 whose type is governance and whose url byte-for-byte equals the token’s iss. Reject if no matching entry exists.
- Use the entry’s
jwks_uri if declared. If absent, default to {origin of iss}/.well-known/jwks.json where origin = scheme+host+port per RFC 6454. Multi-tenant governance agents serving multiple buyers from a shared origin MUST declare explicit per-tenant jwks_uri so tenant key material is not pooled across the origin.
- Fetch the JWKS over HTTPS.
- Locate the key in the JWKS whose
kid matches the token header. On cache miss for a kid, refetch the JWKS once (respecting a minimum 30-second cooldown to prevent unbounded refetches) before rejecting.
JWKS cache TTL MUST be bounded above by the revocation-list polling interval (see Revocation). Longer cache TTLs defeat revocation: if a compromised 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
alg is none or not in the allowed list (EdDSA, ES256). Library defaults MUST NOT be relied upon.
- Reject if header
typ is not exactly adcp-gov+jws (no normalization).
- Reject if the header contains a
crit array and any listed name is not recognized by the verifier.
- Resolve
iss to a JWKS via the discovery rules above. Reject if the JWKS cannot be fetched (after SSRF validation) or the kid is not present after one refetch.
- Verify the JWKS entry’s
use is "sig" and key_ops includes "verify". Reject keys marked for other uses.
- Cryptographically verify the signature.
- Reject if
aud does not byte-for-byte equal the seller’s own canonical URL as declared in the relevant adagents.json entry.
- Reject if
exp is in the past or iat is more than 60 seconds in the future (±60 s clock-skew tolerance, symmetric on both bounds). If nbf is present, reject if now < nbf − 60 s.
- Reject if
sub does not equal the plan_id in the governance call this token is attached to (prevents plan swap).
- Reject if
phase does not match the operation: purchase for create_media_buy; modification for update_media_buy; delivery for delivery-reporting callbacks; intent only for pre-seller buyer-side evaluation.
- For non-intent tokens, reject if
media_buy_id does not equal the media buy ID in the request.
- Cross-check: the token’s
iss MUST 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_jtis or if the token header’s kid ∈ revoked_kids. This check runs on every verification, not only on cache miss.
- Reject if
jti has been seen before for this (iss, aud) tuple. See Replay dedup for storage guidance.
Only after all 15 checks pass does the seller treat the request as governance-approved. Note that sellers do not verify plan_hash — that claim is bound at the governance-agent / auditor layer (see Plan-state binding).
Replay dedup
Step 15 requires tracking jti 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
exp at 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 (Redis SET jti NX EX <remaining_ttl>, Postgres unique index with TTL cleanup) only on bloom-filter hits.
- Governance agents SHOULD issue
jti values 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:
{
"payload": "<base64url of the JSON below>",
"signatures": [
{ "protected": "<b64url header with kid, alg, typ=adcp-gov-revocation+jws>",
"signature": "<b64url signature>" }
]
}
The payload (JWS-flattened JSON serialization; compact form is also acceptable):
{
"version": 1,
"issuer": "https://gov.example.com",
"updated": "2026-04-18T14:00:00Z",
"next_update": "2026-04-18T14:15:00Z",
"revoked_jtis": ["01HWZX..."],
"revoked_kids": ["gov-2026-03"]
}
revoked_jtis invalidates individual decisions (e.g., a plan was rescinded). Revocation applies to any token with that jti, regardless of signing key.
revoked_kids invalidates every token ever signed under that kid (before or after the revocation timestamp), not just tokens issued after.
issuer MUST match the iss origin 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.
Polling cadence:
- 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_update more than 30 minutes in the future for issuers covered by execution-phase traffic. The next_update value 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 slower revoked_kids propagation in exchange for tolerating longer revocation-endpoint outages.
- Polling is optional for intent-phase tokens with ≤15 min
exp (the intent-token exp cap 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.
Fetch failure safe-default: if a seller has not successfully refreshed the revocation list within 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 new kid, and leaving the old key published until the longest-lived outstanding token expires.
- Seller JWKS caches MUST invalidate and refetch on a missing-
kid failure before rejecting (with a 30-second cooldown to prevent unbounded refetches).
- Emergency rotation (key compromise) proceeds by adding the old
kid to the signed revoked_kids list 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. |
Servers MUST NOT echo internal verification details (e.g., which specific claim mismatched) to the counterparty. Return the stable code above; log the detail server-side.
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:
{
"alg": "EdDSA",
"kid": "gov-2026-04",
"typ": "adcp-gov+jws"
}
Payload:
{
"iss": "https://gov.scope3.com",
"sub": "plan_q1_2026_launch",
"plan_hash": "EiCW8FkxgZ2wKqGv3Z9XuT4n2LwcJm1fK7vRaTpQ0sU",
"aud": "https://seller.example.com/adcp",
"iat": 1744934400,
"exp": 1744935300,
"jti": "01HWZXABCDEFG1234567890",
"phase": "intent",
"caller": "https://orchestrator.example.com",
"check_id": "chk_001",
"policy_decision_hash": "9b2a...f41c",
"audit_log_pointer": "https://gov.scope3.com/plans/plan_q1_2026_launch/logs/01HWZXABCDEFG1234567890"
}
Seller verifier (TypeScript, ~30 lines with jose):
import { createRemoteJWKSet, decodeProtectedHeader, decodeJwt, jwtVerify } from "jose";
class GovTokenError extends Error {
constructor(public code: string) { super(code); }
}
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function jwksFor(jwksUri: string) {
let jwks = jwksCache.get(jwksUri);
if (!jwks) {
// ssrfValidatedFetch enforces the Webhook URL validation rules on the JWKS URL
jwks = createRemoteJWKSet(new URL(jwksUri), { cacheMaxAge: 15 * 60 * 1000, cooldownDuration: 30 * 1000, [Symbol.for("fetch")]: ssrfValidatedFetch });
jwksCache.set(jwksUri, jwks);
}
return jwks;
}
export async function verifyGovernanceContext(token: string, ctx: {
sellerId: string; planId: string; mediaBuyId?: string; phase: "intent" | "purchase" | "modification" | "delivery";
resolveBrandJsonGovernanceAgent: (iss: string) => Promise<{ jwks_uri: string } | null>;
seenJti: (iss: string, aud: string, jti: string) => Promise<boolean>;
isRevoked: (iss: string, jti: string, kid: string) => Promise<boolean>;
revocationFresh: (iss: string) => Promise<boolean>;
}) {
const header = decodeProtectedHeader(token);
if (header.typ !== "adcp-gov+jws") throw new GovTokenError("governance_token_invalid");
if (!["EdDSA", "ES256"].includes(header.alg ?? "")) throw new GovTokenError("governance_token_invalid");
const { iss } = decodeJwt(token);
const agent = await ctx.resolveBrandJsonGovernanceAgent(iss as string);
if (!agent) throw new GovTokenError("governance_issuer_not_authorized");
const { payload } = await jwtVerify(token, jwksFor(agent.jwks_uri), {
issuer: iss as string, audience: ctx.sellerId, typ: "adcp-gov+jws",
algorithms: ["EdDSA", "ES256"], clockTolerance: 60,
}).catch(() => { throw new GovTokenError("governance_token_invalid"); });
if (payload.sub !== ctx.planId) throw new GovTokenError("governance_token_not_applicable");
if (payload.phase !== ctx.phase) throw new GovTokenError("governance_token_not_applicable");
if (ctx.phase !== "intent" && payload.media_buy_id !== ctx.mediaBuyId)
throw new GovTokenError("governance_token_not_applicable");
if (!(await ctx.revocationFresh(iss as string))) throw new GovTokenError("governance_revocation_stale");
if (await ctx.isRevoked(iss as string, payload.jti as string, header.kid as string))
throw new GovTokenError("governance_token_revoked");
if (await ctx.seenJti(iss as string, ctx.sellerId, payload.jti as string))
throw new GovTokenError("governance_token_replayed");
return payload;
}
Migration dual-path (sellers during 3.0):
const JWS_COMPACT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
function handleGovernanceContext(value: string, ctx) {
persistOpaque(value); // always persist and forward for auditor use
if (!JWS_COMPACT.test(value)) return; // pre-3.0 opaque value, nothing to verify
return verifyGovernanceContext(value, ctx); // throws on any failure
}
Migration (3.0 → 3.1)
- 3.0: governance agents MUST emit compact JWS per this profile, including the required
plan_hash audit-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_hash remains audit-layer (governance-agent / auditor / buyer-compliance verification only — not seller verification).
The field name and schema shape (single string, ≤4096 chars) do not change between versions. Only the string’s internal format is tightened. This preserves the correlation-key semantics from earlier protocol versions — sellers that already treat the value as opaque need no changes to continue forwarding; sellers that want the accountability properties opt in by implementing the verification checklist.
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’s authorized_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_uri in their operator’s brand.json agents[] 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’s
adagents.json agent entries. Push-notification webhook callbacks (push_notification_config.url and similar asynchronous one-way notifications) are covered by the symmetric Webhook callbacks variant of this profile — the seller signs outbound with an adcp_use: "webhook-signing" key and the buyer verifies.
Dependencies:
- 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 a kid that does not appear on any other JWKS entry with a different adcp_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.
Conformance. Verifier behavior is graded by the universal capability-gated storyboard at /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-signing JWK published at the agent’s jwks_uri — not RFC 9421 base reconstruction over transport headers.
A task is admitted to the designated list only when its response payload is the canonical attestable artifact AND no webhook-emit restructuring is feasible (see the request-the-webhook pattern for the default path). The list in 3.x is closed at:
verify_brand_claim and its bulk variant verify_brand_claims (Brand Protocol). The responding brand-agent signs its response payload as a JWS envelope under the brand’s adcp_use: "response-signing" key. The signature is load-bearing for the direction-asymmetric trust model — see verify_brand_claim trust model and Building a brand agent — Signing setup.
Any task not on this list MUST NOT sign its response under any signing primitive. General-purpose response-signing helpers applying RFC 9421 §2.2.9 to arbitrary tools (regardless of which 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.
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 |
Read calls remain bearer-authenticated. Signing read traffic adds verification cost without proportionate benefit; signing’s purpose is integrity of state-changing operations.
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_capabilities on the target seller. Read request_signing.supported_for and required_for to see which AdCP operations the seller expects you to sign, and read request_signing.protocol_methods_supported_for / protocol_methods_required_for to see which JSON-RPC protocol methods (e.g., tasks/cancel) the seller’s verifier covers. Read covers_content_digest ("required" / "forbidden" / "either") to see whether you must, must not, or may cover content-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 your agents[] entry in brand.json; defaults to /.well-known/jwks.json at 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_for or required_for capability and any JSON-RPC method listed in protocol_methods_supported_for or protocol_methods_required_for, honoring the seller’s covers_content_digest policy. 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 at static/compliance/source/test-vectors/request-signing/) — if your client produces signatures that match the positive vectors’ expected_signature_base, you’re done.
As a verifier (seller):
- Advertise
request_signing.supported: true in get_adcp_capabilities. Leave required_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’s failed_step is informational; an implementation that rejects with the correct error code is conformant even if its internal step numbering differs.
Minimum viable verifier (3.0 shadow mode): steps 1–9, 9a, and 10 of the checklist, in-memory replay cache, one-minute revocation polling with a lightweight 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
tag parameter 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 (GCP roles/cloudkms.signer scoped to the specific cryptoKey, AWS kms:Sign conditioned on the key ARN) so only the AdCP signing path can invoke the key.
Reference implementations: @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:
{
"keys": [
{
"kty": "OKP", "crv": "Ed25519",
"x": "SRYr8eSvjkZF6dAUquI1sKuU4YGZkoGH-2jwkz4dRJg",
"kid": "acme-signing-2026-04",
"alg": "EdDSA", "use": "sig",
"adcp_use": "request-signing",
"key_ops": ["verify"]
},
{
"kty": "OKP", "crv": "Ed25519",
"x": "lHJI-IvBwCE36heDNOyBmCk5UMKRIs4b4BAWJRgao-M",
"kid": "acme-webhook-2026-04",
"alg": "EdDSA", "use": "sig",
"adcp_use": "webhook-signing",
"key_ops": ["verify"]
}
]
}
Distinct 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. |
All six parameters are REQUIRED. Verifiers MUST reject (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 |
When the verifier resolves a 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
Signature or Content-Digest value 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) with request_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 differing Content-Digest bytes across verifiers let an attacker stage a digest mismatch that one verifier accepts and another rejects.
- The
expected_signature_base field in the conformance vectors is independent of binary-value encoding — it contains the canonical signature base bytes, not any header-field encoding. Only the emitted Signature token itself is encoded.
Note on 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. |
The matched value is the JSON-RPC envelope’s 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 keys live at the signing agent’s own jwks_uri in its operator’s brand.json agents[] entry (or the adagents.json equivalent for seller-side agents publishing keys for webhook callbacks). Every agent that signs — of any type — uses the same publication pattern.
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. Publishers hold 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 under their adagents.json entry — 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). |
Cross-purpose key reuse is forbidden and locally enforceable via 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 only
keys.{org}.example/.well-known/jwks.json — request-signing, webhook-signing, TMP keys
Operators SHOULD go further and serve each signing purpose from a distinct subdomain (up to four origins). Defense-in-depth: governance keys SHOULD be on offline-rotation (HSM/KMS with manual rotation and human approval), while transport and webhook keys MAY use automated rotation. Operators advertise their separation scheme by publishing an 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 the identity.brand_json_url field on the agent’s get_adcp_capabilities response — see Discovering an agent’s signing keys via brand_json_url.
- Fetch the agent’s
jwks_uri (or default to /.well-known/jwks.json at the origin of the agent’s url) with SSRF validation per Webhook URL validation. JWKS cache TTL bounded above by the revocation-list polling interval.
- If the
kid is 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 same jwks_uri, the cooldown applies: the verifier MUST NOT refetch again and MUST reject with request_signature_key_unknown. The cooldown is between refetches, not before the first.
Verifiers MUST NOT accept signatures from a 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’s get_adcp_capabilities response with SSRF validation per Webhook URL validation (HTTPS only — the URL A is supplied by the caller and MUST go through the same address-family + private-IP filtering used for webhook callbacks). On unreachable/timeout, reject with request_signature_capabilities_unreachable.
- Read
identity.brand_json_url. If absent and the request is signed, reject with request_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_url MUST be present when the agent declares request_signing.supported_for/required_for non-empty, webhook_signing.supported === true, or any field under identity.key_origins. This is storyboard-enforced in 3.x. In 4.0 the rule becomes schema-required when the response declares supported_versions containing 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 absent identity.brand_json_url.
- Origin binding. The agent URL
A’s host eTLD+1 MUST equal the brand_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 like vercel.app, pages.dev, github.io are treated as suffixes); two verifiers running different PSL versions are non-conformant against each other. If eTLD+1 mismatches, fetch brand.json and check that authorized_operators[] lists A’s eTLD+1. If neither holds, reject with request_signature_brand_origin_mismatch. This closes the shared-tenancy spoofing vector where an attacker stands up an agent on attacker.example/mcp and points its brand_json_url at an unrelated operator’s brand.json that happens to legitimately list attacker.example/mcp (e.g., a SaaS multi-tenant deployment).
- Fetch brand.json at
brand_json_url with SSRF validation per Webhook URL validation. Verifiers MUST NOT follow redirects on this fetch (the single-redirect carve-out for authoritative_location documented 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[] whose url byte-equals A (no canonicalization at this step — same rule as the iss-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 with request_signature_agent_not_in_brand_json. If multiple match (operator misconfig — the brand.json schema does not currently constrain agents[] to be unique-by-URL), reject with request_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_keys pin (when present) is authoritative per the publisher-pin precedence rule above and overrides everything below. The pin is scoped to (agent, webhook-signing purpose, 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’s jwks_uri, defaulting to /.well-known/jwks.json at the origin of A when absent.
identity.key_origins consistency check (mandatory when signing). For every purpose declared under identity.key_origins on the capabilities response whose JWKS source in step 6 was the operator brand.json (i.e., not a publisher adagents.json signing_keys pin), the host of the resolved jwks_uri MUST equal the declared origin for that purpose. Mismatch on any purpose → reject with request_signature_key_origin_mismatch carrying { 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 corresponding identity.key_origins.{purpose} entry, reject with request_signature_key_origin_missing carrying { purpose, posture }.
- Fetch JWKS, find the
kid, verify per the existing RFC 9421 profile (steps 7+ of the verifier checklist).
Trust roots. brand.json is operator-attested (“this agent is mine, here are its keys”). 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_url on its get_adcp_capabilities response 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.
This is the recommended path for sellers like Scope3 building signature verification today: ship the field on your capabilities response, document the chain for your counterparties, and let the 3.x rollout happen passively.
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 — invoke get_adcp_capabilities via the agent’s declared transport (MCP tools/call or A2A skill invocation), not a raw HTTP GET against A. 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. Reject request_signature_brand_json_url_missing if absent (and the request is signed) or non-HTTPS.
- eTLD+1 origin binding. Compute
eTLD+1(A) and eTLD+1(brand_json_url) using a pinned PSL snapshot. Use tldts (TS), publicsuffixlist (Python), or golang.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 fetch brand.json and check authorized_operators[] — if eTLD+1(A) is delegated, proceed. Else reject request_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., raw Example.COM vs example.com, or U-label vs A-label) silently rejects legitimate traffic.
- Fetch
brand.json with the same SSRF rules + no redirects, body cap MAX_BRAND_JSON_BYTES, connect 5 s, total 10 s. Parse with a strict JSON parser that rejects duplicate keys (e.g., secure-json-parse in TS, the stdlib json.JSONDecoder in Python with an object_pairs_hook that raises on duplicates, encoding/json Decoder.DisallowUnknownFields paired 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, reject request_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 whose url byte-equals A (no canonicalization). Reject request_signature_agent_not_in_brand_json on miss; request_signature_brand_json_ambiguous on multiple matches.
- Resolve
jwks_uri from the matched entry — for sell-side webhook-signing only, prefer the publisher’s adagents.json signing_keys pin (when present) over the operator’s jwks_uri. For all other (purpose, role) tuples, use the matched entry’s jwks_uri (default: /.well-known/jwks.json at the origin of A).
- Consistency check. For every purpose declared under capabilities
identity.key_origins, apply canonicalizeOrigin() (ASCII-lowercase + IDNA-2008 A-label) to both the resolved jwks_uri host and the declared origin, then byte-compare (skip only the specific (agent, purpose, role) tuple sourced from a publisher pin). Reject request_signature_key_origin_mismatch / _missing as appropriate.
- Hand off to step 8+ of the verifier checklist — fetch the JWKS (with the same byte budget
MAX_JWKS_BYTES and 5/10 s connect/total deadlines), find the kid (already resolved here in step 7’s preamble — the verifier checklist’s step 7 is the discovery preamble itself), verify per RFC 9421.
Pseudocode (TypeScript-flavored; SDK helpers below collapse this to a single call):
const MAX_CAPABILITIES_BYTES = 65_536;
const MAX_BRAND_JSON_BYTES = 262_144;
const MAX_JWKS_BYTES = 65_536;
const FETCH_BUDGETS = { connect: 5_000, total: 10_000, maxRedirects: 0 };
function canonicalizeOrigin(hostOrUrl: string): string {
const host = hostOrUrl.includes('://') ? new URL(hostOrUrl).hostname : hostOrUrl;
return toAsciiIdna2008(host.toLowerCase()); // A-label form
}
async function resolveAgent(agentUrl: string): Promise<AgentResolution> {
const caps = await getAdcpCapabilities(agentUrl, { // step 1: protocol-level call
...FETCH_BUDGETS, body: MAX_CAPABILITIES_BYTES, ssrf: true,
});
const brandJsonUrl = caps.identity?.brand_json_url;
if (!brandJsonUrl?.startsWith('https://')) throw new Err('brand_json_url_missing'); // step 2
const agentEtld1 = etldPlusOne(new URL(agentUrl).hostname, PINNED_PSL_SNAPSHOT); // step 3
const brandEtld1 = etldPlusOne(new URL(brandJsonUrl).hostname, PINNED_PSL_SNAPSHOT);
const brandJson = await safeFetch(brandJsonUrl, { // step 4
...FETCH_BUDGETS, body: MAX_BRAND_JSON_BYTES, ssrf: true, parse: 'strict-json',
});
if (agentEtld1 !== brandEtld1
&& !brandJson.authorized_operators?.some(o => o.domain === agentEtld1)) {
throw new Err('brand_origin_mismatch');
}
const entries = brandJson.agents.filter(e => e.url === agentUrl); // step 5 (byte-equal)
if (entries.length === 0) throw new Err('agent_not_in_brand_json');
if (entries.length > 1) throw new Err('brand_json_ambiguous');
const entry = entries[0];
const jwksUri = entry.jwks_uri ?? `${origin(agentUrl)}/.well-known/jwks.json`; // step 6
for (const [purpose, declared] of Object.entries(caps.identity?.key_origins ?? {})) { // step 7
if (canonicalizeOrigin(jwksUri) !== canonicalizeOrigin(declared)) {
throw new Err('key_origin_mismatch', { purpose });
}
}
const jwks = await safeFetch(jwksUri, { // step 8 setup
...FETCH_BUDGETS, body: MAX_JWKS_BYTES, ssrf: true, parse: 'strict-json',
});
return { agentUrl, brandJsonUrl, agentEntry: entry, jwksUri, jwks, /* trace, freshness */ };
}
Validate end-to-end against the brand-discovery test vectors at /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 matched agents[] 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 a JWTVerifyGetKey for handing to jose’s jwtVerify.
- Python (
adcp): resolve_agent(url) returns an AgentResolution dataclass with fields agent_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 fields AgentURL, BrandJSONURL, AgentEntry, JWKSUri, JWKS, IdentityPosture, Consistency, Freshness, Trace. VerifyRequestSignature(ctx, req, opts) (*VerifiedIdentity, error) mirrors the TS/Python one-shot.
Each SDK ships a CLI for dev-loop debugging — npx @adcp/sdk 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 whose jwks_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_for capability, AND no Signature-Input header is present, AND the caller presents no other credential the verifier accepts for this operation (bearer, API key, or mTLS), THEN reject with request_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
Signature or Signature-Input is present without the other, reject with request_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 strips Signature-Input but leaves Signature.
- If a
Signature-Input header is present but malformed, reject with request_signature_header_malformed. Verifiers MUST NOT fall back to bearer-only authentication when a malformed signature is present, even for operations not in required_for — a present-but-broken signature signals signer intent; silent fallback enables downgrade attacks.
Otherwise, verifiers MUST apply these 15 checks (14 numbered steps plus sub-step 9a) in order, short-circuiting on the first failure. Step 14 decomposes into 14a (strict-parse requirement) and 14b (logging discipline) — both apply whenever step 14 runs; they are elaborations of one check, not separate checks in the count. This checklist establishes agent identity only — brand-operator authorization is a separate, subsequent check governed by the target house’s brand.json.
-
Parse
Signature-Input and Signature headers per RFC 9421 §4. Reject if malformed.
-
Reject if any of
created, expires, nonce, keyid, alg, or tag is absent from the Signature-Input parameters (request_signature_params_incomplete).
-
Reject if
tag is not exactly adcp/request-signing/v1 (request_signature_tag_invalid).
-
Reject if
alg is 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, or expires − 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 if content-type is not covered. If the verifier’s covers_content_digest capability is "required", reject if content-digest is not covered. If the verifier’s covers_content_digest capability is "forbidden" and content-digest IS covered, reject with request_signature_components_unexpected.
-
Resolve
keyid to 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 via brand_json_url before this step — its 8-step preamble (capabilities → identity.brand_json_url → brand.json → agents[] → jwks_uri) is a precondition for keyid resolution and short-circuits with the request_signature_brand_* and request_signature_key_origin_* codes from that section. On kid miss within an established mapping, refetch once (subject to the 30-second cooldown between refetches) before rejecting with request_signature_key_unknown. Reject if keyid cannot be resolved to a specific agents[] entry.
-
Verify the JWK’s
use is "sig", key_ops includes "verify", and adcp_use equals "request-signing". Reject (request_signature_key_purpose_invalid) on any mismatch — including absent adcp_use, which MUST be treated as non-conforming.
-
Check the Transport revocation list. Reject if
keyid ∈ revoked_kids (request_signature_key_revoked). Reject with request_signature_revocation_stale if the verifier has not refreshed the revocation list within grace.
9a. Per-keyid cap check. Check the per-keyid replay-cache cap. Reject with request_signature_rate_abuse if the cap has been reached for this keyid. 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 after keyid resolution (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-uri canonicalization AND @authority derivation per the profile above. The @authority rule is load-bearing: 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. If both :authority and Host are present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects with request_target_uri_malformed. The canonicalized @authority MUST byte-for-byte match the authority component of the canonical @target-uri; mismatch rejects with request_target_uri_malformed. That byte-match against the signed @target-uri — not the choice of source header — is the only safe gate, because Host itself 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, different Host). After canonicalization completes, verify the signature against the JWK (request_signature_invalid on failure).
-
If
content-digest is covered, recompute the digest from the received body bytes and compare (request_signature_digest_mismatch on 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_malformed is distinct from request_signature_digest_mismatch: the signature IS valid; the body parses to ambiguous state. A verifier that crashes rather than returning a structured request_body_malformed error 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 whose idempotency_key is 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 a request_body_malformed rejection; log keyid, 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.
Only after all 14 checks pass does the verifier treat the request as cryptographically authenticated. Verifiers SHOULD record 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_for operation MUST be rejected with request_signature_required.
- An unsigned but otherwise authenticated request (valid bearer, API key, or mTLS identity; no
Signature-Input) to a required_for operation MUST NOT be rejected for missing signature. The fallback credential is what the verifier advertised as sufficient for that caller, and required_for does 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.
Seller enforcement — pick the posture that matches your capability declaration.Three enforcement postures are valid; sellers MUST pick one and configure their fallback authenticators accordingly. Advertising required_for while letting bearer authentication remain open for the listed operation is security theater — the verifier advertised bearer as valid, and callers are entitled to use it.
- Strict (signing is unconditional for this operation). Sellers MUST either stop accepting bearer/API-key/mTLS for the operation entirely, or gate the fallback authenticator on a per-caller flag that rejects non-signed requests from counterparties who have completed 9421 onboarding. This is the posture where
required_for rejects everything unsigned.
- Prefer signing, accept fallback (recommended during rollout). Advertise
required_for for the operation but leave bearer open. The composition rule applies: unsigned-unauthenticated callers are rejected, unsigned-bearer-authed callers pass. Good for quarters-long migrations where buyers onboard to 9421 at their own pace.
- Advisory only. Move the operation to
warn_for (or supported_for) rather than required_for. The verifier verifies signatures when present and logs failures, but never rejects for missing signature.
Example of the per-caller flag (strict posture): a seller whose agents[] entries carry a signing_onboarded: true flag on 9421-ready counterparties configures its bearer authenticator to reject bearer credentials whose resolved agent has signing_onboarded: true for operations in required_for. Other agents continue to authenticate via bearer until their flag flips. Promotion to required_for stays operationally safe — existing bearer traffic continues while onboarded counterparties are held to the stricter bar.
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
Covering content-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.
Known body-modifying transport patterns. These configurations break body-binding signatures and are the single biggest source of 9421 interop bugs in production:
- CDN configurations that recompress or buffer-modify POST bodies (uncommon, but specific Cloudflare Workers, Fastly VCL, and CloudFront Lambda@Edge setups can introduce byte changes).
- WAFs that “sanitize” JSON request bodies (whitespace normalization, key reordering, unknown field stripping). Most WAFs inspect without modifying; some do modify.
- Reverse proxies or API gateways that re-serialize JSON between client and origin for logging, validation, or transformation.
- HTTP/2 → HTTP/1.1 bridges where chunked-encoding framing assumptions differ.
- Signer-side serialization mismatch. A signer that computes
content-digest over one JSON serialization (e.g., json.dumps(payload) with default spaced separators) while its HTTP client writes a different serialization on the wire (e.g., compact separators) produces a digest over bytes the receiver never sees. Every verifier then rejects with webhook_signature_digest_mismatch or request_signature_digest_mismatch. Serialize the body once, then use those exact bytes for both the digest input and the HTTP body — do not compute the digest from the pre-serialized object and trust the client to reproduce the same bytes. This is the same trap the legacy HMAC scheme pins via compact separators; 9421 fails loud rather than silent (digest mismatch is a hard reject) but the signer-side fix is identical.
If you control the transport, preserve bodies byte-for-byte end-to-end and cover content-digest. If you don’t control the transport, fix it rather than degrade the security guarantee. Validate end-to-end with a POST echo test against a test endpoint before sending real traffic.
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 s to 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
SETNX with EX = 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.
Verifiers MUST NOT use the request bearer token, IP, or any non-(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 their agents[] entries. Format and signing semantics match the governance revocation list (see Revocation above). For request-signing keys:
revoked_kids invalidates every request ever signed under that kid (before or after the revocation timestamp).
revoked_jtis is not used (request signatures don’t have a jti; nonce uniqueness is per-key).
Verifiers accepting request-signed mutations MUST poll the revocation list on the cadence declared in 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 the request_signing block on get_adcp_capabilities:
{
"request_signing": {
"supported": true,
"covers_content_digest": "either",
"required_for": [],
"warn_for": ["create_media_buy"],
"supported_for": [
"create_media_buy",
"update_media_buy",
"sync_creatives",
"activate_signal"
]
}
}
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 cover content-digest; unsigned-body signatures are rejected. "forbidden": signers MUST NOT cover content-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 with request_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 from supported_for to required_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 in warn_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 of required_for and warn_for.
Rollout pattern:
- 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.
In 3.0, verifiers ship with 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 in WWW-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 |
Servers MUST NOT echo internal verification details beyond the stable code; log the detail server-side.
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 the push_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.
{
"webhook_signing": {
"supported": true,
"profile": "adcp/webhook-signing/v1",
"algorithms": ["ed25519", "ecdsa-p256-sha256"],
"legacy_hmac_fallback": false
}
}
supported: MUST be true when the seller advertises mutating-webhook emission elsewhere in its capability surface. Buyers reject onboarding when supported: false or 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 exactly adcp/webhook-signing/v1 for 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 advertised algorithms array 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: true iff the seller supports the legacy HMAC-SHA256 scheme when the buyer populates push_notification_config.authentication.credentials or accounts[].notification_configs[].authentication.credentials. false is the recommended posture in 3.x.
The buyer opts into the legacy HMAC-SHA256 scheme by populating 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" |
Cross-purpose reuse is forbidden and locally enforceable: a request-signing key MUST NOT verify a webhook signature, and a webhook-signing key MUST NOT verify a request signature. Buyers verifying a webhook MUST reject any JWK whose 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. |
JWKS discovery. The buyer knows the seller’s agent URL from the AdCP integration it’s already using. Buyer resolves:
- Seller agent URL
A → fetch /.well-known/brand.json at the operator domain of A with SSRF validation per Webhook URL validation. brand.json resolution follows one redirect (authoritative_location or house redirect variant) and stops.
- In the fetched brand.json, find the
agents[] entry whose url byte-for-byte matches A.
- Fetch that entry’s
jwks_uri (or default to /.well-known/jwks.json at the origin of A) 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
keyid on the incoming Signature-Input to a JWK in the fetched set. On kid miss, refetch once (subject to the 30-second cooldown between refetches) before rejecting with webhook_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.
Buyers MUST NOT derive signer identity from webhook payload fields (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
authentication block. 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
authentication is present on push_notification_config.authentication or any accounts[].notification_configs[].authentication, rejecting with request_signature_required (the same code used for required_for operations — see Transport error taxonomy). When a signed request cryptographically commits to the body, the authentication block 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_mismatch and alarm, not silently downgrade, when they receive a 9421-signed webhook after registering with authentication.credentials, or when they receive HMAC-signed webhooks after registering without authentication. 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 HTTP 401 with 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.
Verifier checklist for webhooks. Apply these 15 checks (14 numbered steps plus sub-step 9a) in order, short-circuiting on the first failure. Step 14 decomposes into 14a (strict-parse requirement) and 14b (logging discipline) — both apply whenever step 14 runs; they are elaborations of one check, not separate checks in the count. The steps below are the request verifier checklist with two parameter substitutions — the 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-Input and Signature headers per RFC 9421 §4. Reject if malformed (webhook_signature_header_malformed). If Signature or Signature-Input is 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, or tag is absent from the Signature-Input parameters (webhook_signature_params_incomplete).
-
Reject if
tag is not exactly adcp/webhook-signing/v1 (webhook_signature_tag_invalid). Byte-for-byte match; no case-folding.
-
Reject if
alg is 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, or expires − 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-digest is REQUIRED; there is no policy branch.
-
Resolve
keyid to a JWK via the JWKS discovery steps above. On kid miss, refetch once (30-second cooldown between refetches) before rejecting (webhook_signature_key_unknown). Reject if keyid cannot be resolved to a specific agents[] entry in the signer’s brand.json.
-
Verify the JWK’s
use is "sig", key_ops includes "verify", and adcp_use equals "webhook-signing". Reject on any mismatch, including absent adcp_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 with webhook_signature_revocation_stale if the verifier has not refreshed within grace.
9a. Per-keyid cap check. Check the webhook replay-cache cap. Reject with webhook_signature_rate_abuse if 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-uri canonicalization AND @authority derivation per the request-signing profile. The @authority rule is load-bearing for webhook security: 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. If both :authority and Host are present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects with webhook_target_uri_malformed. The canonicalized @authority MUST byte-for-byte match the authority component of the canonical @target-uri; mismatch rejects with webhook_target_uri_malformed. That byte-match against the signed @target-uri — not the choice of source header — is the only safe gate, because Host itself 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, different Host). After canonicalization completes, verify the signature against the JWK (webhook_signature_invalid on failure).
-
Recompute
content-digest from the received body bytes and compare (webhook_signature_digest_mismatch on 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 structured webhook_body_malformed error is conformant-but-suboptimal — senders receive no actionable error code. The conformance fixture for this check is the duplicate-keys-conflicting-values vector in static/test-vectors/webhook-hmac-sha256.json — the 9421 profile MUST apply the same body-well-formedness rule after signature verification succeeds. webhook_body_malformed is distinct from webhook_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/gjson in 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-parse is 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/json has no strict mode and does not detect duplicates. Use json.Decoder token-walk with an explicit map[string]struct{} unique-key guard per object scope, OR goccy/go-json with decoder.DisallowDuplicateKey() explicitly enabled (NOT the default). Do NOT use tidwall/gjson for 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.parse has no detection hook. Use Oj.load(..., mode: :strict) with the allow_nan: false / duplicate-rejection options explicitly configured.
14b. Logging discipline. Verifiers SHOULD NOT log full request body bytes on a webhook_body_malformed rejection; log keyid, 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.
Without these constraints, the key-name channel remains an attacker-controlled-byte side channel — smaller than full-body logging but non-zero, and well-precedented as a log-injection vector. Signers that log upstream-input rejections (see the duplicate-object-keys signer-side rule) MUST apply the same (a)/(b)/(c) sanitization rules to any key names surfaced in signer-side error output; the channel shape is identical even though the wire direction is inverted.
A load-bearing invariant for the webhook cache. External traffic without the signer’s private key cannot grow this cache: every entry admitted at step 13 has already passed step 10’s cryptographic verification, so any party driving cache growth is either the legitimate key holder or someone who has compromised the key — the case the per-keyid cap (step 9a) and the new-keyid admission-pressure alarm (see Webhook replay dedup sizing) are designed to detect. The invariant mirrors the analogous request-signing rule (see the “load-bearing invariant for the cap” paragraph immediately after step 13 there). Future edits to the webhook checklist MUST preserve this ordering: moving the step 13 insert before step 10’s signature verification would let any external party flood the cache using forged structurally-valid signatures.
There is no subsequent brand-operator authorization step on the webhook path — the signature establishes the seller’s identity, and that identity is sufficient to accept the webhook. Application-layer dedup on 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 with webhook_signature_rate_abuse and 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 distinct keyids 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).
The four categories are normative; the concrete threshold values are NOT. Operators MUST treat any published example values as starting points, baseline their own traffic, and tune accordingly — published normative threshold numbers would hand attackers an oracle into the detection posture. Concrete starting values, baselining methodology, and attack-scenario walkthroughs are published in the non-normative Webhook Verifier Tuning Guide. Implementations MAY ship the guide’s starting values as first-deployment defaults but MUST expose each threshold as a tunable configuration parameter (e.g., environment variable, config file) — hardcoded starting values become de facto operator-visible defaults and re-introduce the attacker oracle. Implementations SHOULD log or alarm a threshold_tuning_overdue event 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 — once webhook_signature_rate_abuse fires 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).
Cross-endpoint scoping (MUST). A buyer that exposes more than one webhook endpoint (per-integration, per-environment, per-tenant, or per-pod in a horizontally-scaled fleet) MUST either:
- 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-uri after normalisation per the request-signing profile (scheme lowercased, host IDNA-normalised, default port elided, fragment stripped).
Option 1 is stronger — it rejects cross-endpoint replay outright within the ±360 s window. Option 2 is weaker — the same (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 reject authentication 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, prefixed webhook_ 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 |
Retry semantics for verification failures. At-least-once delivery tells senders to retry on any non-2xx response, but a verification failure is not a transient error — the signature bytes and request context arrive identically on every retry, so every retry fails identically. Senders MUST treat a 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 distinct adcp_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_use value
Cross-purpose reuse is prevented automatically because every verifier enforces an exact 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. |
Implementations that ship signing in 3.x SHOULD enable verifier-side 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. The verify9421 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/.
import { createRemoteJWKSet } from "jose";
class RequestSignatureError extends Error {
constructor(public code: string) { super(code); }
}
const ALLOWED_ALGS = new Set(["ed25519", "ecdsa-p256-sha256"]);
const REQUIRED_TAG = "adcp/request-signing/v1";
const REQUIRED_COMPONENTS = new Set(["@method", "@target-uri", "@authority"]);
const REQUIRED_PARAMS = ["created", "expires", "nonce", "keyid", "alg", "tag"] as const;
export async function verifyAdcpRequestSignature(req: Request, ctx: {
operationName: string;
requiredFor: Set<string>;
contentDigestPolicy: "required" | "forbidden" | "either";
resolveJwk: (keyid: string) => Promise<{ jwk: unknown; agentUrl: string }>; // throws _key_unknown after refetch
isKeyRevoked: (keyid: string) => Promise<boolean>;
isRevocationStale: () => Promise<boolean>;
isKeyidAtCapacity: (keyid: string) => Promise<boolean>;
isReplayed: (keyid: string, nonce: string) => Promise<boolean>;
recordNonce: (keyid: string, nonce: string, ttlSeconds: number) => Promise<void>;
verify9421: (req: Request, jwk: unknown, covered: string[]) => Promise<void>; // throws on signature or digest failure
parseSignatureInput: (header: string) => {
keyid?: string; alg?: string; created?: number; expires?: number;
nonce?: string; tag?: string; components: string[];
};
}) {
const sigInput = req.headers.get("signature-input");
// Pre-check: required_for / downgrade protection.
if (!sigInput) {
if (ctx.requiredFor.has(ctx.operationName)) throw new RequestSignatureError("request_signature_required");
return; // operation doesn't require a signature; verify nothing.
}
let parsed;
try { parsed = ctx.parseSignatureInput(sigInput); }
catch { throw new RequestSignatureError("request_signature_header_malformed"); }
// 2: presence
for (const p of REQUIRED_PARAMS) {
if ((parsed as any)[p] == null) throw new RequestSignatureError("request_signature_params_incomplete");
}
// 3: tag
if (parsed.tag !== REQUIRED_TAG) throw new RequestSignatureError("request_signature_tag_invalid");
// 4: alg
if (!ALLOWED_ALGS.has(parsed.alg!)) throw new RequestSignatureError("request_signature_alg_not_allowed");
// 5: window (including expires > created)
const now = Math.floor(Date.now() / 1000);
if (parsed.expires! <= parsed.created! ||
parsed.created! > now + 60 ||
parsed.expires! < now - 60 ||
parsed.expires! - parsed.created! > 300) {
throw new RequestSignatureError("request_signature_window_invalid");
}
// 6: components
for (const c of REQUIRED_COMPONENTS) {
if (!parsed.components.includes(c)) throw new RequestSignatureError("request_signature_components_incomplete");
}
const coversCd = parsed.components.includes("content-digest");
if (ctx.contentDigestPolicy === "required" && !coversCd) {
throw new RequestSignatureError("request_signature_components_incomplete");
}
if (ctx.contentDigestPolicy === "forbidden" && coversCd) {
throw new RequestSignatureError("request_signature_components_unexpected");
}
// 7: JWK resolution
const { jwk } = await ctx.resolveJwk(parsed.keyid!); // throws _key_unknown
// 8: key purpose
const j = jwk as any;
if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.example_use !== "request-signing") {
throw new RequestSignatureError("request_signature_key_purpose_invalid");
}
// 9: revocation (BEFORE crypto verify)
if (await ctx.isRevocationStale()) throw new RequestSignatureError("request_signature_revocation_stale");
if (await ctx.isKeyRevoked(parsed.keyid!)) throw new RequestSignatureError("request_signature_key_revoked");
// 9a: per-keyid cap (BEFORE crypto verify) — prevents amplified crypto work by abusive/misconfigured signer.
if (await ctx.isKeyidAtCapacity(parsed.keyid!)) {
throw new RequestSignatureError("request_signature_rate_abuse");
}
// 10 + 11: crypto verify, content-digest recompute — both inside verify9421.
try { await ctx.verify9421(req, jwk, parsed.components); }
catch (e: any) {
if (e?.code === "digest_mismatch") throw new RequestSignatureError("request_signature_digest_mismatch");
throw new RequestSignatureError("request_signature_invalid");
}
// 12: replay check
if (await ctx.isReplayed(parsed.keyid!, parsed.nonce!)) {
throw new RequestSignatureError("request_signature_replayed");
}
// 13: replay insert (only after all checks pass)
await ctx.recordNonce(parsed.keyid!, parsed.nonce!, (parsed.expires! - now) + 60);
}
Budget Validation
Validate budgets before committing:
async function validateBudget(request, account) {
const { budget } = request;
// Check positive amount
if (budget.amount <= 0) {
throw new ValidationError('Budget must be positive');
}
// Check against account limits
const limits = await getAccountLimits(account.account_id);
if (budget.amount > limits.daily_spend_limit) {
throw new BudgetError('Exceeds daily spend limit');
}
// Check available balance
const balance = await getAvailableBalance(account.account_id);
if (budget.amount > balance) {
throw new BudgetError('Insufficient balance');
}
}
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, nginx ssl_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, no verify=False, no rejectUnauthorized: false anywhere 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.
app.use((req, res, next) => {
// HSTS: 1 year, include subdomains, preload-eligible. MUST be on every HTTPS response.
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// No framing of AdCP API responses — even though they're JSON, frame isolation
// protects any error or debug HTML that could leak through.
res.setHeader('X-Frame-Options', 'DENY');
// MIME sniffing off: responses declare their type, clients MUST respect it.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent referrers leaking to external URLs supplied by counterparties.
res.setHeader('Referrer-Policy', 'no-referrer');
// AdCP endpoints serve no browser-facing HTML — block script-source loading outright.
// If your operator reuses the same origin for a dashboard, adjust this per-path.
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
next();
});
HSTS max-age MUST be ≥ 31536000 (1 year) for any domain serving an AdCP endpoint. 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_location or house variant), 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.json or brand.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.”
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 at 169.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.
Operators that confuse “we have a modern TLS configuration” with “our AdCP deployment is secure” are exactly the operators the body-bound signature profile exists to defend against.
Request Validation
Validate all user-provided input:
const INPUT_LIMITS = {
targeting_brief_max_length: 5000,
creative_upload_max_size: 100 * 1024 * 1024, // 100MB
max_formats_per_request: 50,
max_products_per_query: 100
};
function validateRequest(request) {
// Check string lengths
if (request.brief?.length > INPUT_LIMITS.targeting_brief_max_length) {
throw new ValidationError('Brief exceeds maximum length');
}
// Validate IDs are proper UUIDs
if (request.product_id && !isValidUUID(request.product_id)) {
throw new ValidationError('Invalid product_id format');
}
// Reject unexpected fields
const allowedFields = ['brief', 'product_id', 'budget', 'context_id'];
for (const field of Object.keys(request)) {
if (!allowedFields.includes(field)) {
throw new ValidationError(`Unexpected field: ${field}`);
}
}
}
SQL Injection Prevention
Always use parameterized queries:
// GOOD: Parameterized query (request-supplied account_id after auth precheck)
const result = await db.query(
'SELECT * FROM media_buys WHERE id = $1 AND account_id = $2',
[mediaBuyId, request.account.account_id]
);
// BAD: String concatenation (NEVER do this)
// const result = await db.query(
// `SELECT * FROM media_buys WHERE id = '${mediaBuyId}'`
// );
Audit Logging
Required Log Events
Log all security-relevant events:
const LOG_EVENTS = {
AUTH_SUCCESS: 'auth_success',
AUTH_FAILURE: 'auth_failure',
BUDGET_COMMIT: 'budget_commit',
BUDGET_MODIFY: 'budget_modify',
ACCESS_DENIED: 'access_denied',
WEBHOOK_VERIFIED: 'webhook_verified',
WEBHOOK_REJECTED: 'webhook_rejected'
};
function logSecurityEvent(eventType, details) {
console.log(JSON.stringify({
event: eventType,
timestamp: new Date().toISOString(),
agent_id: details.agentId,
account_id: details.accountId,
ip_address: details.ipAddress,
resource: details.resource,
outcome: details.outcome,
// NEVER log: credentials, PII, targeting briefs
}));
}
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)
For Buyer Agents (AdCP Clients)
For Orchestrators (Multi-Agent, Multi-Account)
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