Documentation Index
Fetch the complete documentation index at: https://docs.adcontextprotocol.org/llms.txt
Use this file to discover all available pages before exploring further.
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.
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. 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, collection-list webhook_url, TMP provider endpoint, adagents.json authoritative_location, and reporting_bucket.setup_instructions.
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.
- 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.
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 mutating AdCP task request (create_media_buy, update_media_buy, sync_creatives, activate_signal, acquire_rights, creative_approval, update_rights, build_creative, calibrate_content, create_content_standards, update_content_standards, create_property_list, update_property_list, delete_property_list, create_collection_list, update_collection_list, delete_collection_list, log_event, provide_performance_feedback, report_usage, report_plan_outcome, si_initiate_session, si_send_message, and the sync_* tasks). Sellers MUST reject any mutating request that omits it with INVALID_REQUEST. 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.
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. For async tasks, the cached response is the submitted result containing task_id. The cache entry is immutable — even if the async task subsequently completes, fails, or is canceled, a replay within the TTL MUST return the originally-cached submitted response (with replayed: true), 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. This preserves the byte-stable cache property and keeps the idempotency layer decoupled from async task lifecycle — sellers don’t need to update cache entries when task state changes.
-
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 ceiling: 60 inserts/sec per agent sustained (3,600/min), with burst allowance up to 300 inserts/sec over rolling 10-second windows. 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 recommended rates bound per-agent residency to ~216,000 entries — the same order of magnitude as the 100,000-entry per-keyid webhook replay cap at Webhook replay dedup sizing, and an order of magnitude below the 1,000,000-entry per-keyid request-replay cap at Transport replay dedup. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-RATE_LIMITED behavior itself is MUST. Sellers MUST expose the ceiling as a tunable configuration parameter — the 60/300/3,600 values are first-deployment starting points sized for a realistic high-volume launch pattern (≤10 media buys/min × 10 packages × 10 creatives, with 3–5× headroom for multi-campaign and retry patterns), not frozen defaults. Operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise the limit rather than accept silent rejection of legitimate traffic; operators with steady low-volume traffic MAY tighten below the starting values. Sellers SHOULD NOT publish their exact configured ceiling numerically in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceiling 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.
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.
Response-level replay indicator
The protocol envelope carries a top-level replayed boolean on responses to mutating requests:
{
"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).
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.
Agent retry vs. network retry. Two cases that look similar but need opposite 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.”
When in doubt, ask whether the serialized request bytes are the same. If yes, reuse the key. If no, the intent changed — 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, so retries send the identical payload even if the planner would produce something slightly different on re-execution.
When the seller’s capability declaration is missing. A seller that omits adcp.idempotency.replay_ttl_seconds from get_adcp_capabilities is non-compliant. Client SDKs MUST fail closed on retry-sensitive operations 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. Read-only operations (get_products, list_accounts, etc.) are safe to issue against such a seller; only mutating requests require the declaration.
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.
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: 15 minutes for any seller accepting execution-phase tokens. Governance agents MUST NOT declare
next_update more than 15 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.
- Polling is optional for intent-phase tokens with ≤15 min
exp.
- 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 = 2× 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.
- 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 symmetric response-signing profile. This profile signs the request; there is no paired profile for signing the synchronous response body, and 3.x defines no adcp_use value for response signing. Sellers MUST NOT sign synchronous AdCP response bodies under any existing adcp_use value, and buyers MUST NOT rely on a signature on the synchronous reply (whether MCP tools/call or A2A non-streaming responses including streaming artifactUpdate frames). Integrity of the immediate response 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.
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 15 min). The fetch-failure safe-default applies: 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 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, 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, or media_buy.content_standards.supports_webhook_delivery: 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. false is the recommended posture in 3.x.
The buyer opts into the legacy HMAC-SHA256 scheme by populating push_notification_config.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 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 push_notification_config 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 15 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 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, 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; 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 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, four signing systems (governance JWS adcp_use: "governance-signing", request-signing 9421 adcp_use: "request-signing", webhook-signing 9421 adcp_use: "webhook-signing", TMP envelope with its own future adcp_use value), each kid-scoped. 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.adcp_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