> ## Documentation Index
> Fetch the complete documentation index at: https://agenticadvertisingorg-snap-format-preview-links.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> AdCP security guide: risk classification for financial operations, webhook HMAC verification, replay prevention, access control, and credential management for production deployments.

<Warning>
  **Critical for Production Use**

  AdCP handles financial commitments and potentially sensitive campaign data. Implementations managing real advertising budgets must implement the security controls outlined in this document.
</Warning>

<Note>
  **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](/docs/building/concepts/security-model).
</Note>

## 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](#webhook-callbacks) — the seller signs outbound webhooks with a key published through its operator `brand.json` `agents[].jwks_uri`, and the buyer verifies against that JWKS. When the publisher's `adagents.json` pins `signing_keys[]` for that seller, the pin is authoritative. Nothing secret crosses the wire; identity is cryptographically established the same way it is for inbound requests.

**9421 webhook signing is baseline-required in 3.0.** Any seller that emits webhooks MUST sign them per the [Webhook callbacks](#webhook-callbacks) profile unless the buyer explicitly opts into the legacy scheme below by populating `push_notification_config.authentication` or `accounts[].notification_configs[].authentication`.

### Legacy HMAC-SHA256 fallback (deprecated, removed in 4.0)

Buyers who need to interoperate with receivers that have not yet adopted the 9421 profile MAY opt in by populating `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`. When `authentication` is present on the buyer's request, the seller signs with HMAC-SHA256 using the semantics defined in [Push Notifications](/docs/building/by-layer/L3/webhooks#legacy-hmac-sha256-fallback). 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](#webhook-callbacks) 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](#webhook-callbacks) (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):

1. Reject if `X-ADCP-Signature` or `X-ADCP-Timestamp` header is missing
2. Reject if timestamp is non-numeric
3. Reject if timestamp is outside the 5-minute window
4. Compute and compare HMAC

**Secret rotation** (legacy scheme):

* Receivers MUST accept signatures from both current and previous secret during rotation
* Rotation window SHOULD NOT exceed the replay window (5 minutes)
* Publishers begin signing with the new secret immediately upon rotation

### Webhook URL validation (SSRF)

Any URL that a buyer, seller, or governance agent provides for another party to fetch is an SSRF vector. This includes `push_notification_config.url`, `accounts[].notification_configs[].url`, collection-list `webhook_url`, TMP provider `endpoint`, `adagents.json` `authoritative_location`, and `reporting_bucket.setup_instructions`.

Account-level webhook subscribers registered through `sync_accounts.accounts[].notification_configs[]` also require endpoint ownership proof before activation. SSRF validation proves the seller is not calling an internal network address; it does not prove the buyer controls the public HTTPS endpoint. Sellers MUST complete an RFC 9421-signed activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active, and the receiver MUST verify the seller identity, delivery auth metadata, and event type set before echoing the challenge. Paused subscribers (`active: false`) may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and paused subscribers MUST NOT receive fires until reactivated. The standard challenge payload and response shape are defined in [sync\_accounts endpoint proof of control](/docs/accounts/tasks/sync_accounts#endpoint-proof-of-control).

Before any outbound fetch to a counterparty-controlled URL, fetchers MUST:

1. **Reject non-HTTPS URLs** in production.
2. **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.
3. **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.
4. **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). Two bounded exceptions, each re-validating steps 1–3 on every hop and capping the chain: the [brand.json resolution](#buyer-identity-resolution) (one redirect, no chains), and the initial `/.well-known/adagents.json` fetch, which follows **same-registrable-domain** redirects only (`apex ↔ www`, HTTPS-preserving, ≤ 3 hops, anchored on the originally-requested domain) so standard apex→www hosting resolves — see [managed networks](/docs/governance/property/managed-networks#why-not-http-redirects). The `adagents.json` `authoritative_location` dereference takes no exception: redirects on that second hop MUST be refused.
5. **Cap response size and timeouts.** Recommended: 5 MB body cap, 10 s connect, 10 s read. The only exception is the dereferenced authoritative file in the managed-network indirection pattern — second-hop only, after a pointer file's `authoritative_location` redirects to the network origin — which uses a recommended 20 MB cap because it fans out across a publisher network. Pointer files themselves stay at 5 MB. See [managed networks security](/docs/governance/property/managed-networks#security-considerations).
6. **Do not echo fetch errors to the agent that supplied the URL.** Detailed error messages (connection refused vs. timed out vs. TLS failure) are a side-channel for probing internal network topology.

#### Destination port: permissive by default

Publishers SHOULD NOT enforce a destination-port allowlist on counterparty-supplied URLs (`push_notification_config.url`, collection-list `webhook_url`, TMP provider `endpoint`, etc.) by default. The URL contract is `format: "uri"` only; the protocol does not constrain ports. Buyers legitimately host webhook receivers on non-standard TLS ports — Tomcat default `:9443`, Spring Boot default `:4443`, path-routed multi-tenant gateways, and per-tenant subdomains-with-port carve-outs — and a default port allowlist silently rejects them with no recourse short of asking the publisher operator to widen the list.

The SSRF guard the protocol relies on is the **IP-range check + DNS-rebinding-resistant connect pin** in steps 2–3 above, not port filtering. The reserved-range check covers the realistic SSRF threat (smuggling traffic to internal services on `10.0.0.0/8`, `127.0.0.0/8`, `169.254.169.254`, etc.); port filtering on top of a routable public IP is a marginal defense whose cost (rejecting conformant buyers) typically exceeds its benefit.

Operators who want a destination-port allowlist as defense-in-depth — for example, locked-down enterprise environments where the publisher's egress firewall already restricts outbound ports — SHOULD opt in explicitly via SDK or deployment configuration, with `{443, 8443}` as a reasonable hardened-mode starting point. SDKs that ship a `DEFAULT_ALLOWED_PORTS` constant MUST default it to "no restriction" and surface `{443, 8443}` as an opt-in profile, never as a default. Sellers that activate hardened mode MUST document the allowed-port set in their operator-facing documentation so buyers can size their integration before discovering the constraint at first-webhook-delivery time.

The wire-level URL contract is **unconstrained beyond `format: "uri"`**; hardened-mode port filtering is an operator-side policy choice, not a protocol-side requirement.

Feature-specific security sections extend these rules with their own lifecycle and content-handling requirements:

* [Offline reporting buckets](/docs/media-buy/media-buys/optimization-reporting#security-considerations-for-offline-delivery) — IAM-layer prefix scoping, credential revocation on account status change.
* [Collection lists](/docs/governance/collection/tasks/collection_lists#security-considerations) — `auth_token` scope and revocation, distribution-ID validation, webhook signature normative rules.
* [Managed networks `authoritative_location`](/docs/governance/property/managed-networks#security-considerations) — validator fetch semantics, change detection, relationship termination.
* [TMP provider registration](/docs/trusted-match/specification#provider-registration-security) — dynamic registration authentication, router-to-provider auth, `/health` info-leakage rules.

## Authentication Best Practices

### Credential Storage

```javascript theme={null}
// 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:

```javascript theme={null}
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](/docs/reference/glossary#a) that owns it. Cross-account reads MUST return a generic "not found" rather than leak existence. The authenticated [agent](/docs/reference/glossary#a) 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:

1. **Bind on create** — permanently associate each object (media buy, creative, session, etc.) with the account used on the request that created it.
2. **Verify on access** — on every subsequent read or modification, verify the authenticated agent has access to the object's bound account.
3. **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](/docs/media-buy/advanced-topics/accounts-and-security#data-isolation) for the billing-relationship model these rules enforce, and the glossary for the formal definitions of [Account](/docs/reference/glossary#a) and [Agent](/docs/reference/glossary#a).

### The two-step pattern

Every account-scoped request whose schema requires `account` carries an explicit `AccountRef` (via `account_id` for account-id namespaces, or the `{brand, operator}` natural key for buyer-declared accounts). A seller MUST NOT silently replace a missing required `account` with a credential-implied default. For tasks where `account` is optional, omission semantics are task-local and must be documented by that task. Correct isolation is two checks, performed in order:

1. **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).
2. **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.

```javascript theme={null}
// 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:

```sql theme={null}
-- 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:

```sql theme={null}
CREATE POLICY account_isolation_list ON media_buys
  FOR SELECT
  USING (account_id = ANY(current_setting('app.authorized_accounts')::uuid[]));
```

### Client-side isolation: cross-principal tool-call confusion

The rules above are server-side enforcement. They protect the seller's data even when a legitimate-but-compromised agent is the caller. The **client-side companion** is the buyer agent's obligation not to let text supplied by principal X drive tool calls that use principal Y's authority.

An LLM-driven buyer agent typically holds credentials for multiple principals at once: several sellers (one credential set per seller) and, inside an agency agent, several brand accounts. Any untrusted string the agent processes — product descriptions returned by a seller, campaign names inherited from a brief, rejection reasons in an error envelope, webhook event bodies — is text sourced from *one* of those principals. If the agent's planning loop can call tools across all of them from a single LLM context, a prompt injected in seller X's text can cause the agent to 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](https://en.wikipedia.org/wiki/Confused_deputy_problem) 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:

1. **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.
2. **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.
3. **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.
4. **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.

### Timestamp format

All timestamp fields in AdCP requests, responses, and webhook payloads MUST be [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) with an explicit timezone offset.

```
✅ 2026-04-19T10:00:00Z            // UTC, recommended
✅ 2026-04-19T10:00:00-04:00       // explicit offset
❌ 2026-04-19T10:00:00             // no offset — ambiguous
❌ 2026-04-19 10:00:00             // not ISO 8601
```

Implementations MUST reject ambiguous ("naïve") timestamps with `INVALID_REQUEST`. Implementations SHOULD use UTC (`Z` suffix) on the wire and convert to local time at the presentation layer.

### Intervals

Any time window in AdCP — flight dates, reporting windows, daypart targeting, idempotency replay TTLs — uses a **half-open interval**: `[start, end)`. The start timestamp is inclusive; the end timestamp is exclusive. A campaign with `start_time: 2026-04-01T00:00:00Z` and `end_time: 2026-05-01T00:00:00Z` runs for April and stops at the first tick of May.

### Daypart targeting

Daypart definitions MUST declare their **timezone semantics** — which of the three meanings the time values carry:

* **Buyer-declared zone** — an IANA zone name alongside the daypart (e.g., `timezone: "America/New_York"`). The daypart is evaluated against that zone regardless of viewer or publisher location. Use this when the buyer wants "9–11pm New York time" enforced globally.
* **Publisher-local** — the daypart is evaluated in the publisher's declared local zone. Use this when the buyer wants "prime time on the publisher's schedule" and is willing to let the publisher decide what that means.
* **Viewer-local** — the daypart is evaluated against each viewer's timezone, resolved at serve time from the viewer's location signal. Use this when the buyer wants "serve at 8pm local" across a global audience.

A daypart with no declared semantics is ambiguous and MUST be rejected with `INVALID_REQUEST`. Sellers MUST honor the declared semantics; if a seller cannot support the requested mode (e.g., a publisher operating in a single zone cannot serve viewer-local dayparting), the seller MUST reject with `INVALID_REQUEST` rather than silently converting. Per-agent defaults are non-normative and MUST NOT be relied on.

## Request Safety

### Idempotency

`idempotency_key` is **required on every AdCP task request** — read and mutating alike. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two.

**Enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). For **read** requests, the rule phases in across two minors:

* **3.1.0** — sellers MUST accept reads that carry `idempotency_key` and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with `INVALID_REQUEST`; sellers MAY accept the omission for the 3.1.x maintenance window.
* **3.2.0** — sellers MUST reject reads that omit `idempotency_key` with `INVALID_REQUEST`. The grace window closes at the 3.2 cut.

This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field uniformly — migrate over a release window rather than at the 3.1 cut. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date.

**Why universal — including read tools.** Several AdCP tasks are polymorphic. `get_products` is the canonical case: `buying_mode: 'brief'` / `'wholesale'` may complete synchronously (pure read), but the same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL, and `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement)). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the wire contract requires `idempotency_key` on every call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer's SDK — is not feasible when the same task name has both read and write modes. Decoding unknown `error.code` values returned by sellers (whether `INVALID_REQUEST` during the grace window or codes added in later minors) follows the [Forward-compatible decoding](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) rule.

This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope.

#### Normative seller behavior

1. **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).

2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. **The cache entry is immutable** — replays within the TTL MUST return the originally-cached payload (with `replayed: true`), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource's current state. This rule applies across both success branches:

   * **Async tasks** — the cached response is the `submitted` result containing `task_id`. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached `submitted` response, NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call.
   * **Synchronous-success tasks** — when the initial response carries state-tracking fields (e.g., `status`, `packages`, `affected_packages` on `create_media_buy`; per-record `status` arrays on `sync_creatives` / `sync_accounts`; resource snapshots on `acquire_rights` / `activate_signal`), replay MUST return the originally-cached payload regardless of intervening mutations to the resource. A media buy that was created with `status: pending_creatives`, then mutated to `canceled` via `update_media_buy`, replays as `status: pending_creatives` — the cached bytes are a historical snapshot of the create-time response, not a current-state read. Buyers MUST consult the resource's read endpoint (`get_media_buys`, `list_accounts`, `list_creatives`, etc.) for current state; see "Buyer obligations" below.

   This preserves the byte-stable cache property uniformly and keeps the idempotency layer decoupled from resource lifecycle — sellers don't need to update cache entries when task or resource state changes. The alternative ("refresh state fields on replay") would force every seller to thread the resource state machine through the idempotency cache, multiply the number of valid cache contents for a given key (a single key's replay would no longer be deterministic across calls), and break the canonical-replay invariant the rest of these rules build on. Sellers MUST NOT implement a hybrid where some state-tracking fields refresh on replay and others do not — partial refresh is the worst of both options and is non-conformant.

3. **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.

4. **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.

5. **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.

6. **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.

7. **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.

8. **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](/docs/building/by-layer/L3/error-handling#rate-limit-handling)) when the per-agent insert rate exceeds the configured ceiling rather than let the cache grow unbounded. A buyer submitting N fresh keys per second on a cheap success-path operation (e.g., `log_event`) would otherwise force unbounded storage, with amplification proportional to `replay_ttl_seconds` at the 3600 s floor. The natural bound is `inserts_per_hour × replay_ttl_hours ≤ max_cache_rows_per_agent`.

   **Recommended ceilings (3.1+):** the original 60/sec sustained / 300/sec burst single-budget ceiling was sized against a write-heavy launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). Under universal idempotency, read traffic also contributes to insert rate — a single agentic dashboard polling `get_products(brief)` + `list_creatives` + `list_accounts` across 5 accounts at 1Hz is \~15 inserts/sec on reads alone, before any write activity. Operators SHOULD adopt a **split budget** per `(authenticated_agent, account)`:

   * **Reads: 300 inserts/sec sustained, 1,500/sec burst over rolling 10s windows.** Dominated by dashboard polling and agentic state re-reads under the [Polling / state re-read](#agent-retry-vs-polling-vs-re-plan) rule. Read traffic is typically bursty during user-driven UI interactions and steady at low rates during agent runs.
   * **Writes: 60 inserts/sec sustained, 300/sec burst.** Unchanged from the original write-heavy sizing — preserved as a separate budget so a buyer's dashboard polling can't exhaust the write capacity that protects `create_media_buy` / `sync_creatives` / `activate_signal` from double-execution races.
   * **Combined cap (defense in depth):** total inserts SHOULD NOT exceed 350/sec sustained / 1,700/sec burst per agent — the sum of the two budgets with a small cushion, so an attacker who saturates the read budget cannot starve write capacity.

   Operators with steady low-volume traffic MAY tighten below these starting values; operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise rather than accept silent rejection of legitimate traffic. The split-budget shape (separate read and write counters) MUST be implemented from 3.1 onward even when operators tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents. The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when `RATE_LIMITED` fires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the combined-cap rates bound per-agent residency to \~1.26M entries — an order of magnitude above the original 216k from the write-only sizing, reflecting the read-traffic addition; per-agent storage budgeting should account for this. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceilings as tunable configuration parameters — the 300/60 read/write split numbers are first-deployment starting points for an agentic-buyer dashboard pattern, not frozen defaults. Sellers SHOULD NOT publish exact configured ceiling numerics in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceilings through the `RATE_LIMITED` + `retry_after` response, not through capability introspection.

   The ceiling is per `(authenticated_agent, account)` — the same scope as the idempotency key itself (bullet 1) — so a multi-account agency does not have its per-account budgets collapsed into a single shared quota. `RATE_LIMITED` rejections MUST populate `retry_after` (seconds) per the [error handling taxonomy](/docs/building/by-layer/L3/error-handling#rate-limit-handling) 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.

9. **Concurrent retries — first-insert-wins.** A second request carrying the same `(authenticated_agent, account_id, idempotency_key)` MAY arrive while the first request is still executing — most commonly when the buyer's transport timeout fires before the seller's downstream call returns, and the buyer retries. Sellers MUST resolve the race deterministically; they MUST NOT execute the side effect twice and MUST NOT silently drop the second request. Resolution is a `(unique constraint, INSERT … ON CONFLICT DO NOTHING)` pattern on the scope tuple: the first row to land owns execution and stores the canonical payload hash on the in-flight row (NOT a sentinel); subsequent requests observe an existing row whose response slot is not yet populated but whose payload hash IS populated.

   Sellers MUST handle the second request by one of two policies and MUST behave consistently across calls — clients infer the policy from the first response within a session and apply it to subsequent retries:

   * **Wait-and-replay** (preferred for fast operations, \<5s typical): the seller blocks the second request until the first completes, then returns the cached response with `replayed: true`. Total wall-time for the second call is bounded by the seller's request-timeout budget.
   * **Reject-and-redirect** (preferred for slow operations involving long-running downstream calls): the seller returns `IDEMPOTENCY_IN_FLIGHT` immediately, with `error.details.retry_after` (seconds, integer) populated based on the first request's elapsed time and expected completion. Buyers MUST retry with the same `idempotency_key` after the hint elapses — a buyer that mints a fresh key on `IDEMPOTENCY_IN_FLIGHT` turns a safe retry into the exact double-execution race this rule prevents.

   A second request with the same key AND a *different* canonical payload during the in-flight window MUST return `IDEMPOTENCY_CONFLICT` (rule 5), not `IDEMPOTENCY_IN_FLIGHT` — the canonical-form mismatch is computable at INSERT time against the row's stored hash, so the conflict is detectable without waiting for the first request's response. Sellers whose backing store cannot persist the real canonical hash until the handler completes (e.g., a placeholder-sentinel pattern) MUST upgrade the store to persist the hash at INSERT time before declaring rule 9 conformance — the alternative (returning `IDEMPOTENCY_IN_FLIGHT` on a same-key-different-payload race and only surfacing the conflict after the first request completes) silently delays detection of a real client bug.

   Per rule 3, if the first request ultimately fails (validation error, downstream timeout, internal error), the `(in_flight)` row is released — the key returns to "never seen" state and a subsequent retry re-executes from scratch. Sellers MUST bound the lifetime of an in-flight row to their declared per-task handler timeout, and MUST release the row (treat as failed per rule 3) when that timeout fires — even if the downstream has not yet responded. Without this bound, a hung handler indefinitely returns `IDEMPOTENCY_IN_FLIGHT` for the same key, locking the buyer out of any safe retry path.

   Sellers using reject-and-redirect MUST set `error.details.retry_after` to a value no greater than `replay_ttl_seconds` (declared in `capabilities.idempotency`). A buyer instructed to wait past the seller's own replay window is being told to wait until the response can no longer be replayed — the wait is vacuous and the buyer either ends up minting a fresh key (the failure mode this rule prevents) or hits `IDEMPOTENCY_EXPIRED` on retry. Sellers SHOULD also declare `capabilities.idempotency.in_flight_max_seconds` — the maximum lifetime of an in-flight row, scoped to the seller's per-task handler timeout. Buyers SHOULD use that declared value as the primary retry-budget bound when present; when absent, fall back to the order-of-magnitude heuristic (a value derived from the seller's typical handler latency, an order of magnitude below the replay TTL, never the TTL ceiling itself).

   Sellers MUST NOT leak the in-flight state across the scope boundary: an attacker probing a candidate key MUST receive the same response shape and timing whether the row exists, is in flight, or has never existed.

10. **Crossing service boundaries — downstream reconciliation.** Sellers commonly invoke downstream systems during request handling — SSP/ad-server calls on `create_media_buy`, payment-provider calls on billing operations, governance-agent calls on `check_governance`. These calls have their own failure modes that can leave the seller in a "downstream unknown" state: the network connection dropped after the downstream accepted the request but before its response arrived; the seller process crashed mid-call; a region failover swapped the worker before the response was persisted. Rule 3 (only successful responses cached) is necessary but not sufficient: a seller that simply doesn't cache and re-executes on retry will double-invoke the downstream and create duplicate side effects there.

    **Conformance grading.** This rule is reviewer-graded, not programmatically graded by the compliance storyboard suite. Black-box observation cannot distinguish "the seller has a claim row" from "the seller got lucky on the test run." The `parallel_dispatch_runner` test-kit lists rule-10 conformance under `reviewer_checks` — sellers attesting to rule-10 conformance MUST surface their operational runbook describing which pattern applies to which downstream, and reviewers verify the implementation against that runbook. The other normative rules (1–9) are programmatically graded.

    Sellers MUST adopt one of two reconciliation patterns for every downstream call whose duplicate-invocation has business consequences (resource creation, payment movement, irreversible state change). Read-only downstream calls (cache lookups, eligibility checks that don't write) are exempt — but borderline cases like fraud-scoring lookups that also write to a downstream audit log count as writes for this rule (the audit log entry is the side effect).

    * **Write-claim-before-invoke (preferred default).** Before invoking the downstream, the seller persists a "claim" row in the same transaction as the idempotency cache row — typically `{idempotency_key, downstream_provider, downstream_request_id, status: 'invoked', invoked_at}` — using the seller-generated `downstream_request_id` it will pass to the downstream as the downstream's own correlation/idempotency identifier. On retry, before invoking the downstream again, the seller MUST look up the claim row by `(idempotency_key, downstream_provider)` and reconcile: query the downstream by `downstream_request_id` to determine the true outcome, then resume cache population from there. The seller MUST NOT treat a missing local record as "downstream call did not happen" — a crash between downstream-accepts and local-persist is exactly the case where it did happen and the local record is missing. If the downstream reports no record of `downstream_request_id` (the claim row was persisted but the seller crashed before invoking), the seller MUST treat the call as not-yet-invoked and proceed with the invocation; the claim row already reserves the `downstream_request_id`, so the downstream's own idempotency will dedup any subsequent retry. On an ambiguous response from the downstream lookup (transient 5xx, network error, malformed response), the seller MUST fail closed — return a transient error to the buyer (so the buyer retries against the same `idempotency_key` per rule 9) rather than proceed with invocation on an unauthenticated "no record" signal.
    * **Thread-buyer-key (acceptable when the downstream protocol supports it).** The seller passes a per-downstream-provider derivative of the buyer's `idempotency_key` as the downstream's own idempotency key — typically `HMAC(K_provider, idempotency_key)` where `K_provider` is derived from the seller's KMS-managed root keyed by provider identity (one key per downstream, not one shared seller secret across all downstreams). Per-provider derivation prevents cross-provider replay if any single downstream is compromised; a shared seller secret across all downstreams collapses every provider into a single key-exposure blast radius. The downstream's at-most-once guarantee then covers the case the seller's local persistence missed. The seller MUST still write a claim row on the success path so the cached response can be populated correctly, but the downstream itself becomes the source of truth on retry. The seller MUST NOT pass the buyer's raw `idempotency_key` to any downstream operated by a different trust principal — the buyer's key is a capability token within its TTL (see "Keys are security-sensitive" below) and forwarding it across a trust boundary widens the capability surface. "Different trust principal" means any system the seller does not operate under the same security boundary; passing the raw key to a purely intra-tenant microservice the seller owns end-to-end (same KMS, same audit log, same operator) does NOT cross a trust boundary and is permitted, though per-provider derivation is still the better default.

    Sellers MUST document which pattern applies to which downstream in their operational runbook. Sellers MUST NOT use a third pattern of "best-effort dedup on downstream response inspection" — comparing the downstream's response payload to a cached fingerprint to decide whether the call already happened — because the downstream's response shape changes across versions and the fingerprint is a synchronization bug waiting to happen. A claim row OR a threaded key. Not pattern-match-on-response.

    Sellers MUST NOT include the buyer's `idempotency_key` (or any reversible derivative thereof) in error envelopes returned to the buyer when those errors originated from the downstream. Downstream errors that mention the seller's per-downstream-provider key (or the buyer's key, if the seller incorrectly threaded it raw) MUST be re-keyed or stripped before propagating to the buyer — otherwise a downstream error message becomes a cross-trust-boundary key-disclosure surface.

    The buyer-visible consequence of this rule: when a seller invokes a slow downstream and the buyer retries during the window, the seller's response on the second request is determined by the seller's policy under rule 9 (`IDEMPOTENCY_IN_FLIGHT` or wait-and-replay), not by the downstream's behavior. Buyers do not need to know which downstream is in the path — the seller MUST present a uniform retry surface regardless.

#### Payload equivalence

"Equivalent" means **identical canonical JSON form**, not field-by-field semantic comparison. Sellers MUST determine equivalence by hashing the canonical form and comparing hashes. The canonical form is [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785) — 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))`.

* TypeScript / JavaScript: [`@truestamp/canonify`](https://www.npmjs.com/package/@truestamp/canonify) or [`canonicalize`](https://www.npmjs.com/package/canonicalize)
* Python: [`pyjcs`](https://pypi.org/project/pyjcs/) or the reference implementation from [RFC 8785 appendix](https://www.rfc-editor.org/rfc/rfc8785)
* Go: [`gowebpki/jcs`](https://github.com/gowebpki/jcs)
* Rust: [`serde_jcs`](https://crates.io/crates/serde_jcs)

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.

#### Server-side tool wrapper conformance

Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) **uniformly across all AdCP tool calls** — buyers cannot know per-tool which envelope fields the seller's wrapper happens to declare. Servers MUST tolerate envelope-level fields that arrive in tool params but are not declared in the tool's parameter schema. Concretely:

* **`idempotency_key`** is required on every AdCP task request (see rule 1 above — read and mutating alike). Tool wrappers MUST accept it; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant.
* **`context_id`, `context`, `push_notification_config`, `governance_context`** MUST be accepted on every tool, including reads. Tools that don't consume a given field MUST ignore it; they MUST NOT reject the call because the envelope field is present.

This is the server-side counterpart to the `additionalProperties: true` default that every published AdCP request schema declares. Configuring a server-side validator in a way that contradicts the schema's own `additionalProperties` declaration is a conformance violation. Common server-implementation traps:

* **FastMCP / Pydantic with strict signatures** — a tool wrapper declared as `def get_products(brief: str)` raises `unexpected_keyword_argument` when the buyer sends `idempotency_key` inside the same params object. Fix: declare `idempotency_key: str | None = None` (and the other envelope fields) as accept-and-ignore optional parameters, or use a `**kwargs` catch-all and discard unknown keys. Pydantic-on-input uses `Extra.allow` or `model_config = ConfigDict(extra='allow')`.
* **Zod / valibot with `.strict()`** on the inbound request schema rejects unknown keys for the same reason; remove `.strict()` on input schemas, or compose with a passthrough variant.
* **OpenAPI-generated server stubs** with `additionalProperties: false` injected by the codegen tool — verify the generated input schema mirrors the spec's `additionalProperties: true` default; some generators flip the default during model emission.

The wire-level invariant is: a buyer SDK MUST be able to send the same envelope-field set to every AdCP tool on every seller, and any seller that rejects on envelope fields breaks the cross-seller portability the protocol promises. This rule is normative for 3.1+; pre-existing wrappers that reject envelope fields are non-conformant at the next maintenance bump.

Reference: this rule generalizes the per-validator pattern already established for response-side validators in [`runner-output-contract.yaml` > `response_schema_validator_semantics`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/universal/runner-output-contract.yaml) — both rules express the same principle ("validator configuration MUST NOT contradict the schema's own `additionalProperties` declaration") on the two ends of the wire.

#### Response-level replay indicator

The protocol envelope carries a top-level `replayed` boolean on responses to any request that resolved via the idempotency cache:

```json theme={null}
{
  "status": "completed",
  "replayed": true,
  "timestamp": "2026-04-18T14:35:00Z",
  "payload": {
    "media_buy_id": "mb_01HW7J8K9P0Q1R2S3T4U5V6W7X"
  }
}
```

`replayed` is produced by the seller's idempotency layer at response time, not stored in the cache. On a fresh execution it is `false` (or omitted — buyers MUST treat omission as `false`). On a cached replay it is `true`; the inner `payload` is byte-for-byte what was stored on the original successful execution. Envelope fields (`timestamp`, `context_id`, etc.) may differ — they describe the current response, not the cached one.

Buyers use `replayed` for:

* **Agent side-effect suppression** — an agent that acts on response data before a human sees it (notifications, downstream tool calls, memory writes) MUST check `replayed` to avoid re-emitting on retry. "Campaign created!" notifications, LLM memory inserts, and downstream agent calls are exactly what silent replay breaks.
* **Side-effect invariants** — downstream systems expecting exactly-once event semantics read `replayed` before treating the response as a new event.
* **Billing reconciliation** — "we processed N buys this month" counts `replayed: false` only.
* **Logging** — distinguishing "retry succeeded by returning cache" from "retry triggered a new execution" (the latter usually signals a bug in the replay window or key management).
* **State-machine routing** — state-tracking fields in the cached `payload` (e.g., `status: pending_creatives` on a replayed `create_media_buy`) are a historical snapshot, not a current-state read (see seller rule 2 and "Replay responses are historical snapshots" under buyer obligations). Buyers MUST re-read via the resource's read endpoint before any state-dependent action.

#### IDEMPOTENCY\_CONFLICT response shape

Standard AdCP error envelope. The error body:

* MUST include `code: "IDEMPOTENCY_CONFLICT"` and a human-readable `message`
* MUST NOT include the cached response, the original payload, a canonical-form diff, or any fingerprint derived from them. A `field` json-pointer hint seems harmless but reveals schema shape (e.g., `/packages/0/budget` tells an attacker the victim's payload had a budget in the first package). Sellers MUST NOT emit one. A legitimate buyer debugging a retry can diff their own two payloads — they have both.

```json theme={null}
{
  "errors": [
    {
      "code": "IDEMPOTENCY_CONFLICT",
      "message": "idempotency_key was used with a different payload within the replay window. Either resend the exact original payload (to return the cached response) or generate a fresh UUID v4 to submit this new payload.",
      "recovery": "correctable"
    }
  ],
  "context": { "correlation_id": "..." }
}
```

Leaking cached state turns key-reuse into a read oracle. An attacker who guesses or steals a victim's key could otherwise probe it to infer payload structure. The error body exposes only the code.

#### SI send\_message idempotency model

`si_send_message` needs a narrower scope than other mutations because conversational turns advance session state. The key is scoped `(authenticated_agent, account_id, session_id, idempotency_key)`.

* **Retry of turn N within the TTL returns the cached response for turn N**, even if turn N+1 has since been accepted. Idempotency returns what you did, not rewinds what the session is. The buyer's retry is asking "did my message get through" — the answer is still "yes, here's what came back."
* **A new `si_send_message` with a fresh `idempotency_key` is a new turn**, processed against the current session state. Buyers MUST generate a fresh key per logical turn, not per HTTP attempt.
* **If the seller has advanced session state past turn N and cannot reproduce the cached response byte-for-byte** (e.g., the session was pruned for storage), the seller MAY return `SESSION_NOT_FOUND` or `IDEMPOTENCY_EXPIRED` rather than reconstruct. Buyers retrying far past a session timeout should expect this.

#### Buyer obligations

Buyers MUST generate a unique `idempotency_key` per `(seller, request)` pair. Reusing the same key across sellers allows colluding sellers to correlate requests from the same buyer. Use a fresh UUID v4 for each request. On retry after a network error, buyers MUST resend the exact same payload with the same key — changing either side breaks at-most-once semantics. In particular, buyers MUST NOT change `push_notification_config.url` between retries with the same key; URL is part of the canonical hash and rotating it triggers `IDEMPOTENCY_CONFLICT`. Rotate the key when changing webhook configuration.

**Network retry vs. agent re-plan vs. polling / state re-read.** Three cases that look similar but need different handling:

* **Network retry** — socket timeout, 5xx, transient failure. The buyer has the *same intent* and sent the *same bytes* — and MUST resend them with the *same key*. This is what idempotency\_key exists for.
* **Agent re-plan** — the buyer is an agent whose planner re-ran (prompt re-executed, tool output changed, policy re-evaluated) and produced a *different payload*. The intent has changed. The agent MUST mint a *new key* and treat the prior request as abandoned. Reusing the prior key with a different canonical payload returns `IDEMPOTENCY_CONFLICT`, which is the seller correctly telling the agent "you're not retrying, you're doing something new."
* **Polling / state re-read** — a dashboard polling `get_products(brief)`, `list_creatives`, `list_accounts` at intervals; a buyer agent reading `get_media_buys` to fetch fresh state after a mutation; any "give me current state at time T" call. Buyers MUST mint a fresh `idempotency_key` per call. Reusing the prior poll's key would replay the cached snapshot (up to `replay_ttl_seconds`), silently returning stale data — exactly the failure mode the cache exists to prevent on mutations. This rule also governs the re-read step in the [Replay responses are historical snapshots](#replay-responses-are-historical-snapshots) pattern below: the "re-read for current state" call MUST carry a fresh key, never the key from the mutation it's reading state for.

When in doubt, ask whether the buyer's intent is **"give me the same answer as before"** (network retry — reuse the key) or **"give me the current answer"** (polling / state re-read — mint a new key) or **"do this new thing"** (agent re-plan — mint a new key). Agentic clients that loop through an LLM to build the request SHOULD freeze and cache the serialized bytes alongside the key on first send for the network-retry case, so retries send the identical payload even if the planner would produce something slightly different on re-execution.

**Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from rules 1–9 of this section. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every other AdCP task request remains subject to rules 1–9; the fail-closed obligation below applies once the capability fetch has completed.

**When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed on every subsequent AdCP task request against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance immediately rather than after a silent double-booking. The fail-closed rule applies to every AdCP task request (other than `get_adcp_capabilities` itself) now that `idempotency_key` is required universally — including calls that resolve as pure reads, because the buyer cannot predict at call time whether a polymorphic task (`get_products` brief vs. refine+finalize vs. async-Submitted) will resolve as a read or a mutation, and the missing TTL declaration means the seller is unsafe to retry against in any mode.

**Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/docs/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/docs/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default.

**Replay responses are historical snapshots.** A response carrying `replayed: true` is byte-equivalent to the original first-call response (per seller rule 2) — state-tracking fields in it reflect the resource's state at first-call time, NOT the resource's current state. A buyer that reads `status: pending_creatives` from a replayed `create_media_buy` response and then calls `update_media_buy(canceled: true)` on a resource that has actually been in `canceled` for hours will surface a `NOT_CANCELLABLE` error and a state-machine bug. Buyers requiring current state MUST consult the resource's read endpoint — `get_media_buys` for media buys, `list_accounts` for accounts, `list_creatives` for creatives, `get_signals` for signals, equivalents for other resources. `replayed: true` is the explicit signal that a fresh read is required before any state-dependent decision; SDKs SHOULD surface the flag to caller code rather than transparently unwrap it. Agentic buyers MUST treat `replayed: true` as a stop signal for any planning step whose next action depends on resource state, and MUST re-read before continuing.

**The re-read MUST carry a fresh `idempotency_key`.** Reusing the key from the mutation whose state you're re-reading either returns `IDEMPOTENCY_CONFLICT` (if the read payload differs from the mutation payload — almost always true) or, worse, returns the cached mutation response itself (if the payloads happen to match). Reusing a *prior read's* key returns that prior read's cached snapshot — the exact stale-state failure mode this rule exists to prevent. State re-reads fall under the Polling / state re-read case above; mint a new key per call.

**TTL boundary for persisted keys.** Some buyers persist `idempotency_key` alongside their own object (e.g., `campaign.pending_idempotency_key` in the buyer's DB) so that retries after a process restart or overnight reconcile still dedup. This works **only within the seller's declared `replay_ttl_seconds`**. Beyond the TTL, the seller will either reject the retry with `IDEMPOTENCY_EXPIRED` (good) or, if the cache was evicted, treat it as a new request (silent double-booking — the failure mode this field exists to prevent). Buyers retrying past the TTL MUST fall back to a natural-key check (e.g., query `get_media_buys` by `context.internal_campaign_id`) before resending. The `idempotency_key` guarantees at-most-once execution within the replay window, not forever. Queue-based retry systems and workflow engines with retry horizons longer than the seller's TTL MUST be designed around this — don't put a key into a dead-letter queue that replays days later without a natural-key re-check.

**Keys are security-sensitive.** An `idempotency_key` is a secret capability token within its TTL — anyone who holds one and knows the original payload can replay it and read the cached response. Treat keys the way you treat session tokens: do not log them in full, do not embed them in URLs, do not share them across agents. Log prefix-only (first 8 chars of the UUID) if you need correlation. Buyers persisting `pending_idempotency_key` at rest (e.g., alongside a campaign row in the buyer's DB) MUST encrypt it with the same controls used for bearer tokens, and SHOULD purge the key after success confirmation to minimize the exposure window.

**Sellers MUST encrypt the cache tier at rest.** Under universal idempotency (3.1+), the cache holds read-tool responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`, etc.) in addition to the write receipts it held in 3.0.x. Those read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller's underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by `(authenticated_agent, account_id)` at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant's cached read response.

**Keys MUST be unguessable.** Schema enforces `^[A-Za-z0-9_.:-]{16,255}$` and buyers MUST use UUID v4 (\~122 bits of entropy) or an equivalent CSPRNG-generated value. Low-entropy keys like `retry-001` or monotonic counters turn the cache into an enumerable surface: an attacker can walk the key space and test each one against a target agent. Sellers SHOULD reject keys that fail a basic entropy check (e.g., all-zeros, repeated characters, short ASCII words) with `INVALID_REQUEST` when the authenticated agent is not individually trusted.

**The three-state response (`success` / `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_EXPIRED`) is an existence oracle for idempotency keys.** An attacker who holds a candidate key can probe it: `success` means never seen, `IDEMPOTENCY_CONFLICT` means live with a different payload, `IDEMPOTENCY_EXPIRED` means previously used. The per-`(agent, account)` scoping above is the primary defense — an attacker authenticated as agent A cannot probe agent B's keys, and a caller scoped to account A cannot probe account B's keys even under a shared agent credential. Unguessable keys are the secondary defense — an attacker who cannot guess a victim's key cannot probe the oracle usefully. Sellers MUST NOT surface `IDEMPOTENCY_EXPIRED` across scope boundaries or to unauthenticated callers. Sellers SHOULD also avoid distinguishable timing between "key exists" and "key does not exist" lookups in the idempotency layer; a constant-time floor on the negative path closes a side channel that persists even without an error-code oracle.

**SI session scope.** For `si_send_message` the key is scoped `(authenticated_agent, account_id, session_id, idempotency_key)`. `session_id` is therefore part of the oracle surface: if session IDs are guessable, an attacker who steals one key can probe it against many sessions. SI sellers MUST generate `session_id` server-side using a CSPRNG with ≥122 bits of entropy (UUID v4 or equivalent) and MUST NOT derive it from anything observable to another agent (request sequence number, user handle, timestamps). The same idempotency\_key sent with a different `session_id` is a different scope tuple — always a new request, never a conflict.

**`account_id` entropy for cache-scope safety.** `account_id` is part of every idempotency scope tuple, so it is also part of the oracle surface: an attacker authenticated as agent A with a stolen idempotency key could probe it against candidate account IDs to enumerate accounts in A's authorized set or learn which accounts A has ever operated on. When account IDs are short sequential or semantic values (`acct_123`, `nike-us`), this is a real enumeration channel. Sellers that issue server-assigned account IDs MUST use unguessable values (UUID v4 / ULID, ≥122 bits of entropy) for any account ID that participates in an idempotency cache scope. Sellers operating under the buyer-declared account model (natural-key `{brand, operator}`) MUST hash the natural key with a seller-local salt before using it as a cache-scope component — the natural key is public by design and cannot be used directly as an oracle defense.

```javascript theme={null}
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](#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](/docs/governance/campaign/specification#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](#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](#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](/docs/governance/campaign/specification#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:

1. **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`.
2. **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.
3. **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):

1. Establish the buyer domain via the rules in [Buyer identity resolution](#buyer-identity-resolution).
2. 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.
3. 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. Sharding is also a size requirement, not only an isolation one: each `jwks_uri` is fetched under the `MAX_JWKS_BYTES` budget (64 KiB — see the verifier pseudocode below), which is the JWKS-specific cap and is deliberately tighter than the generic 5 MB SSRF body ceiling. A single JWKS that pools hundreds of per-tenant keys exceeds 64 KiB and is rejected, so per-tenant `jwks_uri` (each serving a small key set) — not one aggregated document — is the conformant path at scale.
4. Fetch the JWKS over HTTPS.
5. 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](#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](#webhook-url-validation-ssrf): 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:

1. Parse the compact JWS. Reject if malformed.
2. Reject if header `alg` is `none` or not in the allowed list (EdDSA, ES256). Library defaults MUST NOT be relied upon.
3. Reject if header `typ` is not exactly `adcp-gov+jws` (no normalization).
4. Reject if the header contains a `crit` array and any listed name is not recognized by the verifier.
5. 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.
6. Verify the JWKS entry's `use` is `"sig"` and `key_ops` includes `"verify"`. Reject keys marked for other uses.
7. Cryptographically verify the signature.
8. Reject if `aud` does not byte-for-byte equal the seller's own canonical URL as declared in the relevant `adagents.json` entry.
9. 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`.
10. Reject if `sub` does not equal the `plan_id` in the governance call this token is attached to (prevents plan swap).
11. 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.
12. For non-intent tokens, reject if `media_buy_id` does not equal the media buy ID in the request.
13. 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](#buyer-identity-resolution)). Sellers SHOULD cache brand.json with reasonable TTLs (recommend 1 hour) and refresh on verification failure.
14. Check the revocation list (see [Revocation](#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.
15. Reject if `jti` has been seen before for this `(iss, aud)` tuple. See [Replay dedup](#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](#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:

```json theme={null}
{
  "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):

```json theme={null}
{
  "version": 1,
  "issuer": "https://gov.example.com",
  "updated": "2026-04-18T14:00:00Z",
  "next_update": "2026-04-18T14:15:00Z",
  "revoked_jtis": ["01HWZX..."],
  "revoked_kids": ["gov-2026-03"]
}
```

* `revoked_jtis` invalidates individual decisions (e.g., a plan was rescinded). Revocation applies to any token with that `jti`, regardless of signing key.
* `revoked_kids` invalidates every token ever signed under that `kid` (before or after the revocation timestamp), not just tokens issued after.
* `issuer` MUST match the `iss` origin of tokens this list governs. Prevents cache substitution across issuers by a shared CDN.
* The list is signed so a compromised CDN or DNS origin cannot serve a stale or tampered list to un-revoke a compromised key.

**Polling cadence**:

* Sellers MUST poll the list on the cadence declared in `next_update`.
* Floor: 1 minute. Ceiling: 30 minutes for any seller accepting execution-phase tokens. Governance agents MUST NOT declare `next_update` more than 30 minutes in the future for issuers covered by execution-phase traffic. The `next_update` value is a JSON timestamp, not an HTTP cache header — standard HTTP caches will not respect it; sellers MUST parse and honor it themselves. Sellers that prioritize fast key-compromise propagation over DoS tolerance SHOULD poll at or near the floor; the ceiling exists for sellers that accept slower `revoked_kids` propagation in exchange for tolerating longer revocation-endpoint outages.
* Polling is optional for intent-phase tokens with ≤15 min `exp` (the intent-token `exp` cap from the JWT claims table above — distinct from the polling ceiling, even though the numbers were previously coincident).
* Use HTTP conditional requests (`If-Modified-Since` / `ETag`) to avoid unnecessary body transfers.

**Fetch failure safe-default**: if a seller has not successfully refreshed the revocation list within `next_update + grace` (recommend grace = 4× the previous polling interval), the seller MUST reject any new `purchase`, `modification`, or `delivery` phase token until the list is refreshed. This prevents an attacker who DoSes the revocation endpoint from extending the fraud window of a compromised key. Sellers operating at the polling ceiling get \~2.5 h of endpoint-outage tolerance; sellers at the floor get \~5 min. Tune the polling cadence — not the grace constant — to your risk appetite.

* Governance agents MUST retain revoked public keys as discoverable for the audit retention period (recommend 7 years) so auditors can verify historical tokens after the current rotation. Revoked keys SHOULD be served at `{origin}/.well-known/jwks-archive.json` (separate from the active JWKS).

#### Key rotation

* Governance agents rotate by adding a new key to JWKS with a new `kid`, signing fresh tokens with the new `kid`, and leaving the old key published until the longest-lived outstanding token expires.
* Seller JWKS caches MUST invalidate and refetch on a missing-`kid` failure before rejecting (with a 30-second cooldown to prevent unbounded refetches).
* Emergency rotation (key compromise) proceeds by adding the old `kid` to the signed `revoked_kids` list and rotating to a new key immediately. Short exp on intent tokens, capped exp on execution tokens, and revocation-list polling together bound the fraud window.

#### Verification error taxonomy

Sellers and client libraries SHOULD surface verification failures with these codes so that retry vs reject semantics are consistent across the ecosystem. AdCP client libraries (`@adcp/sdk` and equivalents) SHOULD expose typed errors that map to this taxonomy.

| Failure                                                                   | Retry?            | Code                                                        | Notes                                                                                                                                     |
| ------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| JWKS fetch timeout or 5xx                                                 | Yes, with backoff | `governance_jwks_unavailable`                               | Transient. Retry with exponential backoff; abort after N attempts.                                                                        |
| JWKS fetch fails SSRF validation                                          | No                | `governance_jwks_untrusted`                                 | Permanent. Indicates misconfigured `jwks_uri` or an attack.                                                                               |
| `kid` not in JWKS after refetch                                           | No                | `governance_key_unknown`                                    | Reject. Possibly indicates rotation lag or key revocation.                                                                                |
| Signature invalid, `typ` mismatch, `alg` not allowed, `crit` unknown      | No                | `governance_token_invalid`                                  | Reject. Indicates tampering or implementation bug.                                                                                        |
| `exp` in past, `jti` replayed, `nbf` in future                            | No                | `governance_token_expired` / `_replayed` / `_not_yet_valid` | Reject. Tokens cannot be healed by retry.                                                                                                 |
| `jti` ∈ `revoked_jtis` or `kid` ∈ `revoked_kids`                          | No                | `governance_token_revoked`                                  | Reject.                                                                                                                                   |
| `iss` not in buyer brand.json                                             | No                | `governance_issuer_not_authorized`                          | Reject. Possibly indicates a spoofing attempt.                                                                                            |
| Revocation list not refreshed within grace                                | No (block new)    | `governance_revocation_stale`                               | Reject new tokens until revocation list refreshes. Existing fully-verified tokens may continue to be trusted within their existing grace. |
| `aud` mismatch, `sub` mismatch, `phase` mismatch, `media_buy_id` mismatch | No                | `governance_token_not_applicable`                           | Reject. Token valid but not for this operation.                                                                                           |

Servers MUST NOT echo internal verification details (e.g., which specific claim mismatched) to the counterparty. Return the stable code above; log the detail server-side.

#### Privacy considerations

**`policy_decisions` visibility**: the token is a JWS (readable by anyone with the public key), not a JWE (encrypted). If `policy_decisions` contains the full list of policy IDs the governance agent evaluated, every seller who receives the token learns which policies the buyer's governance posture considers — competitive intelligence, and in some cases signaling about sensitive audience characteristics (e.g., a `minors_compliance` policy ID implies targeting of under-18 audiences). Governance agents SHOULD use `policy_decision_hash` in place of `policy_decisions` when the buyer's compliance posture is sensitive; the full log remains available to auditors via `audit_log_pointer` with governance-agent-controlled access.

<span id="intent-phase-disclosure" />**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:

```json theme={null}
{
  "alg": "EdDSA",
  "kid": "gov-2026-04",
  "typ": "adcp-gov+jws"
}
```

Payload:

```json theme={null}
{
  "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`)**:

```ts theme={null}
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)**:

```ts theme={null}
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](/docs/governance/campaign/specification#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.

<a id="request-signing" />

### Signed Requests (Transport Layer)

[Signed Governance Context](#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](#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 operator's brand.json `agents[]` entry. Push-notification webhook callbacks (`push_notification_config.url` and similar asynchronous one-way notifications) are covered by the symmetric [Webhook callbacks](#webhook-callbacks) variant of this profile — the seller signs outbound with its `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` value is still accepted) and the buyer verifies.

**Dependencies:**

* Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the [AdCP JWS profile](#adcp-jws-profile) above. Request verification never accepts another key purpose: 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](#agent-key-publication). The webhook path has its own explicit relaxation because the webhook `tag` provides domain separation.
* Resolves the identity-bootstrapping dependency in [Buyer identity resolution](#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`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests), which runs for any agent advertising `request_signing.supported: true`. The storyboard exercises every step in the [verifier checklist](#verifier-checklist-requests) below and every canonicalization-edge rule in this profile, against the test vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/). To run the CLI grader against your own agent, see [Auth Graders](/docs/building/verification/grading).

**No general-purpose RFC 9421 response-signing profile.** This profile signs the *request*; AdCP 3.x defines no general-purpose paired profile for signing the synchronous response *transport*. Sellers MUST NOT apply RFC 9421 §2.2.9 response signing to synchronous AdCP responses (whether MCP `tools/call` or A2A non-streaming responses including streaming `artifactUpdate` frames), and buyers MUST NOT rely on an RFC 9421 response signature on the synchronous reply. Integrity of the immediate response transport rests on TLS within the authenticated session that carried the request, modulo the standard edge-termination caveats that govern request-side body integrity at body-modifying CDNs. Durable at-rest attestation for artifacts that need to survive past the session — including specialism-scoped payloads (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts, bilateral non-repudiation receipts such as `plan_receipt`) — is the job of [signed webhooks](#webhook-callbacks) (signed with the `adcp_use: "request-signing"` key). The split is deliberate — see [Security Model: What gets signed](/docs/building/concepts/security-model#what-gets-signed--and-what-doesnt) for the full rationale and the request-the-webhook pattern for tools whose canonical artifact needs to be attestable.

<a id="designated-task-response-signing" />

**Designated-task payload-envelope response signing.** A closed list of tasks designates their response *payload* as cryptographically signed under `adcp_use: "response-signing"`. The primitive is distinct from RFC 9421 §2.2.9 transport response signing on two load-bearing axes:

* **Signature location:** inside the response body, not in HTTP response headers.
* **Verification path:** parse the response body, then verify the JWS against the responding agent's `response-signing` JWK published at the agent's `jwks_uri` — not RFC 9421 base reconstruction over transport headers.

A task is admitted to the designated list only when its response payload is the canonical attestable artifact AND no webhook-emit restructuring is feasible (see the [request-the-webhook pattern](/docs/building/concepts/security-model#the-request-the-webhook-pattern) for the default path). The list in 3.x is closed at:

* **`verify_brand_claim`** and its bulk variant **`verify_brand_claims`** (Brand Protocol). The responding brand-agent signs its response payload as a JWS envelope under the brand's `adcp_use: "response-signing"` key. The signature is load-bearing for the direction-asymmetric trust model — see [`verify_brand_claim` trust model](/docs/brand-protocol/tasks/verify_brand_claim#trust-model) and [Building a brand agent — Signing setup](/docs/brand-protocol/building-a-brand-agent#signing-setup).

Any task not on this list MUST NOT sign its response under any signing primitive. General-purpose response-signing helpers applying RFC 9421 §2.2.9 to arbitrary tools (regardless of which `tag` or `adcp_use` string they coin) are operating outside this profile and are not 3.x-conformant; the only response-signing primitive the spec authorizes in 3.x is payload-envelope JWS on the designated-tasks list.

The `adcp_use: "response-signing"` value is therefore reserved at the JWK layer for the payload-envelope primitive. **Keys published with `adcp_use: "response-signing"` MUST sign only payload-envelope JWS as defined in this section; using such a key to produce RFC 9421 §2.2.9 transport signatures is a profile violation regardless of the task being signed.** If a future major version scopes RFC 9421 transport response signing for any task, it MUST use a distinct `adcp_use` value (e.g., `"response-transport-signing"`) so verifiers can disambiguate the primitive from the JWK alone — the brand-protocol value cannot be retconned to cover both. List growth and additional primitives are normative decisions deferred to future spec versions.

Designated-task success responses MUST carry a `signed_response` member matching [`response-payload-jws-envelope.json`](https://adcontextprotocol.org/schemas/v3/core/response-payload-jws-envelope.json). The envelope payload is the canonical signed task-body object and MUST include `typ: "adcp-response-payload+jws"`, `task`, `brand_domain`, `agent_url`, `request_hash`, `iat`, `exp`, and `response`. The outer task-body fields are convenience fields for ordinary task consumers; verifiers that rely on the signature MUST reject the envelope if any unsigned task-body field disagrees with `signed_response.payload.response`. Protocol/version envelope fields are excluded from this comparison, including `status`, `context_id`, `task_id`, `message`, `timestamp`, `replayed`, `adcp_version`, and `adcp_major_version`.

This profile uses ordinary JWS signing, not RFC 7797 unencoded payloads. The JWS Signing Input is `BASE64URL(UTF8(protected)) || "." || BASE64URL(UTF8(JCS(payload)))`, where `payload` is `signed_response.payload` and `protected` decodes to `{ "alg": "EdDSA" | "ES256", "kid": "...", "typ": "adcp-response-payload+jws" }`. The protected header MUST NOT contain `b64`. Response verifiers MUST enforce the shared JWS discovery and hardening rules from the [AdCP JWS profile](#adcp-jws-profile): allowed algorithms, `use: "sig"`, `key_ops` containing `"verify"`, exact `adcp_use: "response-signing"`, missing-`kid` refetch, revocation checks, SSRF-safe JWKS fetches, and duplicate-key rejection before canonicalization.

`request_hash` is `sha256:` plus unpadded base64url SHA-256 of the JCS canonical request-binding object `{ task, brand_domain, agent_url, caller_identity, request }`. `caller_identity` MUST be a typed canonical string derived from the authenticated transport or credential mapping, such as `signed-agent-url:<agents[].url>`, `api-client-id:<seller-issued client id>`, or `mtls-san:<lowercased SAN>`. If no authenticated caller identity exists, `caller_identity` is `null` and the verifier MUST treat the response as weaker evidence that is not bound to a caller.

`brand_domain` is a tenant-binding field, not an echo. A multi-brand agent MUST populate it from its server-side tenant resolution and the brand.json entry whose policy store produced the answer; it MUST NOT copy it from the request body. `agent_url` is the canonical URL of the responding `agents[]` entry whose `response-signing` JWK verifies the envelope. Online verifiers MUST reject envelopes at or after `exp` after applying only small clock-skew tolerance; audit verifiers MAY verify after `exp`, but only as historical evidence that the brand-agent signed that payload during the stated `iat`/`exp` window.

Response-signing keys are scoped by brand tenant as well as by purpose. A shared multi-brand fleet MUST publish distinct response-signing key material and distinct `kid` values for each `brand_domain` it serves, even when the same software and `agent_url` serve multiple brands. Cross-brand reuse of a response-signing JWK is a profile violation because it defeats tenant-bound replay analysis; this rule is stricter than ordinary cross-purpose separation and applies only to `adcp_use: "response-signing"` keys.

#### Transport scope

| Class                                                                                     | 3.0                                     | 4.0          |
| ----------------------------------------------------------------------------------------- | --------------------------------------- | ------------ |
| Spend-committing (`create_media_buy`, `update_media_buy`, `acquire_*`, `activate_signal`) | Optional, capability-advertised         | Required     |
| Reversible state changes (`sync_creatives`, `update_creative_status`)                     | Optional                                | Recommended  |
| Read / discovery (`get_products`, `get_media_buy_delivery`, `list_*`)                     | Not in scope                            | Not in scope |
| TMP `provider_endpoint_url` requests                                                      | Out of scope (TMP has its own envelope) | Out of scope |

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:**

0. 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`.
1. Generate an Ed25519 keypair: `openssl genpkey -algorithm ed25519 -out signing-key.pem`.
2. Export the public key as a JWK. Add `"kid"`, `"use": "sig"`, `"key_ops": ["verify"]`, `"adcp_use": "request-signing"`, and `"alg": "EdDSA"`.
3. 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).
4. 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](#production-key-storage) below.
5. Validate end-to-end with the conformance vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/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):**

1. Advertise `request_signing.supported: true` in `get_adcp_capabilities`. Leave `required_for: []` during the pilot; add operations incrementally per counterparty.
2. Enable signature verification middleware on mutating routes. Implement the [verifier checklist](#verifier-checklist-requests) — all 14 checks (13 numbered steps plus sub-step 9a), short-circuit on first failure.
3. 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.
4. 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`](https://github.com/adcontextprotocol/adcp-client/blob/main/examples/gcp-kms-signing-provider.ts). See the [SDK signing guide](https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/SIGNING-GUIDE.md#step-35-production-key-storage--kms--hsm--vault) 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. Note that webhooks do not need their own purpose: they are signed with a `"request-signing"` key (see [Webhook callbacks](#webhook-callbacks), step 8), so an operator that signs both requests and webhooks with the same key publishes a single `"request-signing"` entry. An operator that wants separate key material for webhooks (blast-radius isolation) publishes a **second `"request-signing"` key with a distinct `kid`** — isolation comes from the `kid`, not a distinct `adcp_use`. The `adcp_use` value is always a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject:

```json theme={null}
{
  "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": "request-signing",
      "key_ops": ["verify"]
    }
  ]
}
```

This second entry is the optional webhook-isolation key from the [Key publication](#webhook-callbacks) section: same `adcp_use: "request-signing"`, distinct `kid`, used to sign webhooks so a webhook-key compromise doesn't extend to request signing. 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](#content-digest-and-proxy-compatibility).                                 |

**`@target-uri` canonicalization** follows the [AdCP URL canonicalization rules](/docs/reference/url-canonicalization) — 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`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/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](https://github.com/adcontextprotocol/adcp/issues/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:

1. Signers MUST emit base64url-no-padding only. A signer that emits a `Signature` or `Content-Digest` value containing `+`, `/`, or `=` is non-conformant.
2. 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.
3. 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.
4. 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-uri`s 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, and outbound webhooks are signed with `"request-signing"` keys (optionally with distinct webhook-only key material under a separate `kid`). Every agent that signs — of any `type` — uses the same publication pattern. Publisher `adagents.json` may additionally pin allowed seller keys through `authorized_agents[].signing_keys[]`; when present, that pin is authoritative for the scoped sell-side authorization.

**Publisher pin precedence.** When a publisher's `adagents.json` entry for an authorized agent carries a `signing_keys` pin (see [`adagents.json` §`signing_keys`](/docs/governance/property/adagents#signing_keys)), that pin is authoritative: verifiers MUST reject any signature whose `keyid` is not in the pinned set, regardless of `jwks_uri` contents. The agent-hosted JWKS is advisory whenever a publisher pin exists. This closes the agent-domain-compromise window — an attacker who takes over the agent's domain cannot silently swap both the endpoint and its advertised keys because the publisher's pin still governs acceptance. Publishers are required to pin for any agent whose delegated scopes include mutating operations; see the adagents.json rule for rotation and cache semantics.

Each request-signing JWK entry MUST declare:

| Member     | Value                  | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `use`      | `"sig"`                | Standard JWK signing use.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `key_ops`  | `["verify"]`           | Verifier-visible JWKS declares verify-only. The signing operator holds the corresponding private key locally with `["sign"]` per JWK spec.                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `adcp_use` | `"request-signing"`    | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile) and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. The same `"request-signing"` key (or a second one under a distinct `kid`) also signs outbound webhooks — see [Webhook callbacks](#webhook-callbacks). (`"webhook-signing"` is a deprecated purpose pending removal — see [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); still accepted on the webhook path for backward compatibility.) |
| `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`, except for the explicit webhook relaxation above: a `"request-signing"` key may sign either requests or webhooks because the RFC 9421 `tag` separates those profiles. 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/webhook RFC 9421 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 keys (including webhook-only `kid`s), deprecated webhook-signing keys during the backward-compatibility window, and TMP keys

Operators SHOULD go further and serve each signing surface from a distinct subdomain. 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. `webhook_signing` names the webhook delivery surface, not a required live `adcp_use: "webhook-signing"` purpose; it may point at the same origin as `request_signing` when webhooks use request-signing keys. 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 request/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:

1. 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`](#discovering-an-agents-signing-keys-via-brand_json_url).
2. 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](#webhook-url-validation-ssrf). JWKS cache TTL bounded above by the revocation-list polling interval.
3. 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:

1. Fetch `A`'s `get_adcp_capabilities` response with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf) (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`.
2. 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`.
3. **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](https://publicsuffix.org/list/public_suffix_list.dat) 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).
4. Fetch brand.json at `brand_json_url` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). 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.
5. 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](#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`.
6. Resolve the JWKS source by **signing surface AND role** (sender-vs-receiver position, not just `adcp_use`):
   * **Sell-side webhook delivery 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 delivery surface, sell-side role) — it does NOT override operator-side webhook deliveries (e.g., a buyer-hosted webhook receiving operator status callbacks), and it does not imply a separate `adcp_use: "webhook-signing"` key purpose.
   * **All other (surface, role) tuples** — request signing (any direction), operator-side webhook delivery, 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.
7. **`identity.key_origins` consistency check (mandatory when signing).** For every surface/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 surface/purpose. Mismatch on any surface/purpose → reject with `request_signature_key_origin_mismatch` carrying `{ purpose, expected_origin, actual_origin }`. Skip the check **only** for the specific (agent, surface/role) tuple whose source was a publisher pin — operator-side use of the same surface 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 }`.
8. Fetch JWKS, find the `kid`, verify per the existing RFC 9421 profile (steps 7+ of the [verifier checklist](#verifier-checklist-requests)).

**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.                                                                                                      |

<Info>
  **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](https://github.com/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.
</Info>

##### Quickstart: implement a `brand_json_url`-based verifier

Mirrors the [request-signing quickstart](#quickstart-opt-into-request-signing-in-30) above. Run-once-per-agent — the resulting `agents[]` entry, `jwks_uri`, and JWKS are cached per the TTL rules in step 4.

1. **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](#webhook-url-validation-ssrf): HTTPS only, address-family + private-IP filtering, no redirects, with budgets `{ connect: 5000, total: 10000, body: MAX_CAPABILITIES_BYTES, maxRedirects: 0 }`.
2. **Read `identity.brand_json_url`.** Reject `request_signature_brand_json_url_missing` if absent (and the request is signed) or non-HTTPS.
3. **eTLD+1 origin binding.** Compute `eTLD+1(A)` and `eTLD+1(brand_json_url)` using a pinned PSL snapshot. Use [`tldts`](https://www.npmjs.com/package/tldts) (TS), [`publicsuffixlist`](https://pypi.org/project/publicsuffixlist/) (Python), or [`golang.org/x/net/publicsuffix`](https://pkg.go.dev/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.
4. **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`](https://www.npmjs.com/package/secure-json-parse) in TS, the stdlib `json.JSONDecoder` in Python with an `object_pairs_hook` that raises on duplicates, [`encoding/json`](https://pkg.go.dev/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.
5. **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.
6. **Resolve `jwks_uri`** from the matched entry — for sell-side webhook delivery only, prefer the publisher's `adagents.json signing_keys` pin (when present) over the operator's `jwks_uri`. For all other (surface, role) tuples, use the matched entry's `jwks_uri` (default: `/.well-known/jwks.json` at the origin of `A`).
7. **Consistency check.** For every surface/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, surface/role) tuple sourced from a publisher pin). Reject `request_signature_key_origin_mismatch` / `_missing` as appropriate.
8. **Hand off to step 8+ of the [verifier checklist](#verifier-checklist-requests)** — 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):

```ts theme={null}
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/`](https://adcontextprotocol.org/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`](https://github.com/adcontextprotocol/adcp-client)): `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`](https://github.com/adcontextprotocol/adcp-client-python)): `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](#verifier-checklist-requests) in one call.
* **Go** ([`adcp-go`](https://github.com/adcontextprotocol/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@latest resolve <url>`, `adcp resolve <url>` (also `python -m adcp resolve <url>`), `adcp resolve <url>` (Go binary, same name as the Python one — disambiguate by `$PATH` or vendor) — printing the trace with per-step `fetched_at`/`age_seconds`/`ok` so an operator triaging a `request_signature_brand_*` failure can see exactly which step rejected and why. Both the Python (`[project.scripts]` console\_scripts entry) and Go (binary `adcp`, distinct from the Go module path `github.com/adcontextprotocol/adcp-go`) toolchains install a top-level `adcp` command so a single muscle-memory invocation works across runtimes.

#### Agent identity

A valid signature establishes exactly one fact: **the request was issued by the agent 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](#verifier-checklist-requests). `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](#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.

1. Parse `Signature-Input` and `Signature` headers per RFC 9421 §4. Reject if malformed.

2. Reject if any of `created`, `expires`, `nonce`, `keyid`, `alg`, or `tag` is absent from the `Signature-Input` parameters (`request_signature_params_incomplete`).

3. Reject if `tag` is not exactly `adcp/request-signing/v1` (`request_signature_tag_invalid`).

4. Reject if `alg` is not in the allowlist (`ed25519`, `ecdsa-p256-sha256`). Library defaults MUST NOT be relied upon (`request_signature_alg_not_allowed`).

5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`request_signature_window_invalid`).

6. 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`.

7. Resolve `keyid` to a JWK via [Agent key publication](#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`](#discovering-an-agents-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.

8. 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.

9. Check the [Transport revocation](#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](#transport-replay-dedup). 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.

10. 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](#adcp-rfc-9421-profile). **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).

11. If `content-digest` is covered, recompute the digest from the received body bytes and compare (`request_signature_digest_mismatch` on mismatch).

12. Check the nonce against the replay cache (see [Transport replay dedup](#transport-replay-dedup)). Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`request_signature_replayed`).

13. **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.

14. **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](#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](#webhook-callbacks) 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](#webhook-callbacks) 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](#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](#verifier-checklist-requests) 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.

<Warning>
  **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.
</Warning>

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.**

<Warning>
  **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](#legacy-hmac-sha256-fallback-deprecated-removed-in-40); 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.
</Warning>

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](#verifier-checklist-requests) 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](#verifier-checklist-requests) — 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](#revocation) above). For request-signing keys:

* `revoked_kids` invalidates every request ever signed under that `kid` (before or after the revocation timestamp).
* `revoked_jtis` is not used (request signatures don't have a `jti`; nonce uniqueness is per-key).

Verifiers accepting request-signed mutations MUST poll the revocation list on the cadence declared in `next_update` (floor 1 min, ceiling 30 min). The fetch-failure safe-default applies with grace = 4× the previous polling interval: verifiers that have not refreshed within `next_update + grace` MUST reject new request-signed mutations with `request_signature_revocation_stale` until the list is refreshed.

#### Transport capability advertisement

Verifiers advertise signing support and per-call requirements via the `request_signing` block on `get_adcp_capabilities`:

```json theme={null}
{
  "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](#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:**

1. Announce signing readiness: add the operation to `supported_for`. Counterparties can begin signing but nothing changes if they don't.
2. 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.
3. 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](#verification-error-taxonomy) so SDK error handling is symmetric.

| Failure                                                                                                                                                                                                                                                                                                                                                                        | Retry?             | Code                                      |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | ----------------------------------------- |
| Unsigned request where signing is required — either (a) operation is in `required_for`, or (b) request payload carries a field that triggers signing regardless of `required_for` membership (e.g., `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` on a signing-capable seller — see [Webhook callbacks](#webhook-callbacks)) | No                 | `request_signature_required`              |
| Request `@target-uri` is syntactically malformed (e.g., empty authority, bare IPv6, IPv6 zone identifier, raw non-ASCII host), OR canonicalized `@authority` does not byte-match the authority component of the canonical `@target-uri` (cross-vhost replay)                                                                                                                   | No                 | `request_target_uri_malformed`            |
| `Signature` or `Signature-Input` header present but malformed                                                                                                                                                                                                                                                                                                                  | No                 | `request_signature_header_malformed`      |
| Required sig-param absent (`created`, `expires`, `nonce`, `keyid`, `alg`, or `tag`)                                                                                                                                                                                                                                                                                            | No                 | `request_signature_params_incomplete`     |
| `tag` not `adcp/request-signing/v1`                                                                                                                                                                                                                                                                                                                                            | No                 | `request_signature_tag_invalid`           |
| `alg` not in allowlist                                                                                                                                                                                                                                                                                                                                                         | No                 | `request_signature_alg_not_allowed`       |
| Signature window invalid (`expires ≤ created`, skew, expired, > 5 min validity)                                                                                                                                                                                                                                                                                                | No                 | `request_signature_window_invalid`        |
| Required covered components missing                                                                                                                                                                                                                                                                                                                                            | No                 | `request_signature_components_incomplete` |
| Covered components include `content-digest` when capability is `"forbidden"`                                                                                                                                                                                                                                                                                                   | No                 | `request_signature_components_unexpected` |
| `keyid` not in signer JWKS after one refetch                                                                                                                                                                                                                                                                                                                                   | No                 | `request_signature_key_unknown`           |
| JWK `key_ops` lacks `verify`, `use` ≠ `sig`, or `adcp_use` ≠ `request-signing`                                                                                                                                                                                                                                                                                                 | No                 | `request_signature_key_purpose_invalid`   |
| `keyid` ∈ `revoked_kids`                                                                                                                                                                                                                                                                                                                                                       | No                 | `request_signature_key_revoked`           |
| Revocation list not refreshed within grace                                                                                                                                                                                                                                                                                                                                     | No (block new)     | `request_signature_revocation_stale`      |
| Cryptographic verification failed                                                                                                                                                                                                                                                                                                                                              | No                 | `request_signature_invalid`               |
| `content-digest` mismatch with recomputed digest                                                                                                                                                                                                                                                                                                                               | No                 | `request_signature_digest_mismatch`       |
| Body contains duplicate object keys (parser-differential vector)                                                                                                                                                                                                                                                                                                               | No                 | `request_body_malformed`                  |
| Nonce already seen within window                                                                                                                                                                                                                                                                                                                                               | No                 | `request_signature_replayed`              |
| Per-keyid replay cache exceeded its entry cap                                                                                                                                                                                                                                                                                                                                  | No (block new)     | `request_signature_rate_abuse`            |
| JWKS fetch transient failure                                                                                                                                                                                                                                                                                                                                                   | Yes (with backoff) | `request_signature_jwks_unavailable`      |
| JWKS fetch fails SSRF validation                                                                                                                                                                                                                                                                                                                                               | No                 | `request_signature_jwks_untrusted`        |

Servers MUST NOT echo internal verification details beyond the stable code; log the detail server-side.

**`WWW-Authenticate` format.** AdCP does NOT define a realm value for request-signing challenges. Verifiers MUST emit `WWW-Authenticate: Signature error="<code>"` with no `realm` parameter and no other parameters. Clients parsing the header MUST tolerate other parameters (RFC 7235 permits implementations to include extras) but SHOULD NOT depend on them.

#### Webhook callbacks

Push-notification webhooks (POSTs to the `push_notification_config.url` a buyer registers), account-level webhooks (POSTs to `accounts[].notification_configs[].url`), and similar asynchronous seller-initiated callbacks are signed under a symmetric variant of this profile. Role direction is inverted relative to request signing: the **seller signs outbound**, the **buyer verifies**. 9421 webhook signing is baseline-required for any 3.0 seller that emits webhooks, with a deprecated HMAC fallback described in [Webhook Security](#webhook-security).

**Baseline with programmatic advertisement.** 9421 webhook signing is baseline-required for any seller that emits webhooks — the default is signed, not a negotiated option. The `webhook_signing` capability block on `get_adcp_capabilities` exists so buyers can detect a non-signing seller *at onboarding* rather than discovering it by traffic inspection (which is how the asymmetry with `request_signing` manifested before this block was restored). A seller whose capability surface advertises mutating-webhook emission elsewhere (e.g., `media_buy.reporting_delivery_methods` includes `webhook`, `media_buy.content_standards.supports_webhook_delivery: true`, or `wholesale_feed_webhooks.supported: true`) MUST include this block with `supported: true`. A seller that emits no webhooks MAY omit the block entirely; `supported: false` is reserved for the unsafe posture of emitting unsigned webhooks and MUST NOT be used to signal absence-of-webhooks. Buyers that integrate with a seller whose surface advertises mutating-webhook emission while the `webhook_signing` block advertises `supported: false` or is omitted MUST fail onboarding with a user-actionable error — a seller that emits but does not sign webhooks is unsafe to integrate with for any mutating-webhook use case.

```json theme={null}
{
  "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](#verifier-checklist-requests), reused for webhooks via the substitutions noted above). Buyers MUST reject onboarding with a user-actionable error if the advertised `algorithms` array contains any value outside this set; an out-of-set algorithm indicates a misconfigured or non-conforming seller and silent acceptance would defeat the allowlist.
* `legacy_hmac_fallback`: `true` iff the seller supports the legacy HMAC-SHA256 scheme when the buyer populates `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`. `false` is the recommended posture in 3.x.

The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`; otherwise the seller signs with the 9421 webhook profile. Sellers MAY decline to support the legacy scheme — see the `legacy_hmac_fallback` flag above.

**Mode selection is a switch, not both.** The presence of `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` selects exactly one signing mode for every webhook delivered to that URL: `authentication` present → legacy HMAC-SHA256 (or Bearer); `authentication` absent → 9421. Sellers MUST NOT sign the same webhook both ways. Buyers MUST NOT attempt "try 9421 first, fall back to HMAC" verification — that pattern creates downgrade oracle behavior and accepts signatures the buyer did not ask for. Verifiers key the verification path strictly off whether the receiver has a configured HMAC secret for the webhook registration.

**Key publication.** 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. Webhooks are signed with the agent's **`adcp_use: "request-signing"`** key; there is no separate webhook key purpose. Domain separation between requests and webhooks is carried by the signature `tag` (`adcp/request-signing/v1` vs `adcp/webhook-signing/v1`), not by the key purpose. Each signing JWK MUST declare:

| Member     | Value                                                                                    |
| ---------- | ---------------------------------------------------------------------------------------- |
| `use`      | `"sig"`                                                                                  |
| `key_ops`  | `["verify"]`                                                                             |
| `adcp_use` | `"request-signing"`                                                                      |
| `kid`      | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` |
| `alg`      | `"EdDSA"` or `"ES256"`                                                                   |

**Key isolation is optional, via a distinct `kid` — not a distinct purpose.** An operator that wants its webhook traffic signed by separate key material (so a webhook-key compromise does not extend to request signing, or to rotate the two independently) publishes a **second `adcp_use: "request-signing"` key with a distinct `kid`** and signs webhooks with that one. Both keys carry the same `adcp_use`; the verifier resolves the right one by the `kid` in `Signature-Input`. No dedicated webhook key purpose is required to achieve isolation.

> **Deprecated:** `adcp_use: "webhook-signing"` is deprecated and scheduled for removal in a future major version (tracked in [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); the exact window is a WG/RFC decision). Verifiers MUST still accept it for backward compatibility (a webhook signed under a `"webhook-signing"` key verifies cleanly), but new signers SHOULD publish and sign with `"request-signing"` keys only.

A buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"request-signing"` (or the deprecated `"webhook-signing"`), and MUST reject any other key-purpose failure — any other `adcp_use` value, absent `adcp_use`, or a missing `verify` key\_op — with `webhook_signature_key_purpose_invalid`. The reverse is still forbidden: request verification requires `adcp_use == "request-signing"` exactly (a request signature is rejected if the key declares any other purpose), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The webhook path is permissive on key purpose because it already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there.

**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 within the same JWKS are forbidden so each `kid` resolves to exactly one key. Webhooks are signed with the agent's `request-signing` key, so by default a request-signing-key compromise extends to webhooks. An operator that needs blast-radius separation publishes a second `request-signing` key with a distinct `kid` dedicated to webhook delivery and signs webhooks with it — isolation comes from separate key material under a separate `kid`, not from a separate `adcp_use`.

**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](#adcp-rfc-9421-profile).                                                                                                                                                                                      |
| `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:

1. Seller agent URL `A` → fetch `/.well-known/brand.json` at the operator domain of `A` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). brand.json resolution follows one redirect (`authoritative_location` or `house` redirect variant) and stops.
2. In the fetched brand.json, find the `agents[]` entry whose `url` byte-for-byte matches `A`.
3. Fetch that entry's `jwks_uri` (or default to `/.well-known/jwks.json` at the origin of `A`) with SSRF validation. JWKS cache TTL bounded above by the revocation-list polling interval (floor 1 min, ceiling 30 min). Long-running task flows cross JWKS rotations; verifiers MUST NOT pin a single JWKS snapshot for the lifetime of a task.
4. Resolve `keyid` on the incoming `Signature-Input` to a JWK in the fetched set. On `kid` miss, refetch once (subject to the 30-second cooldown between refetches) before rejecting with `webhook_signature_key_unknown`. The refetch-on-miss path is the load-bearing mechanism for handling mid-task key rotation — clients that skip it will reject legitimate post-rotation deliveries.

Buyers MUST NOT derive signer identity from webhook payload fields (`task_id`, `operation_id`, etc.) or from `adagents.json` entries — those are publisher authorization, not signer identity. Identity is established solely via the signature → JWKS → seller `agents[]` entry chain.

**Downgrade and injection resistance.** The buyer's webhook-signing preference is communicated by the presence or absence of `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` on the inbound request that registers the webhook. In 3.0 that inbound request is frequently bearer-authenticated rather than 9421-signed, so an on-path mutator (misconfigured proxy, compromised intermediary) could strip or inject the `authentication` block silently. The following rules contain the blast radius:

* **Sellers MUST log** every request that arrives with a non-empty `authentication` block. Ops alarms on unexpected HMAC selection protect the buyer side when the buyer thought it was getting 9421.
* **Sellers that support request signing MUST require** the inbound request to be 9421-signed (per the [request verifier checklist](#verifier-checklist-requests)) when `authentication` is present on `push_notification_config.authentication` or any `accounts[].notification_configs[].authentication`, rejecting with `request_signature_required` (the same code used for `required_for` operations — see [Transport error taxonomy](#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](#transport-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](#verifier-checklist-requests) 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.

1. 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.

2. Reject if any of `created`, `expires`, `nonce`, `keyid`, `alg`, or `tag` is absent from the `Signature-Input` parameters (`webhook_signature_params_incomplete`).

3. Reject if `tag` is not exactly `adcp/webhook-signing/v1` (`webhook_signature_tag_invalid`). Byte-for-byte match; no case-folding.

4. Reject if `alg` is not in the allowlist (`ed25519`, `ecdsa-p256-sha256`). Library defaults MUST NOT be relied upon (`webhook_signature_alg_not_allowed`).

5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`webhook_signature_window_invalid`).

6. 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.

7. 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.

8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is `"request-signing"` — webhooks are signed with the agent's request-signing key (see [Key publication](#webhook-callbacks)); the deprecated `"webhook-signing"` value MUST also be accepted for backward compatibility. Reject on any other outcome with `webhook_signature_key_purpose_invalid`: absent `adcp_use`, a missing `verify` key\_op, or any other `adcp_use` value (e.g. `"response-signing"`, `"governance-signing"`). Accepting `"request-signing"` here is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. (`webhook_mode_mismatch` is reserved for the HMAC-vs-9421 auth-mode selector mismatch — see [Downgrade and injection resistance](#webhook-callbacks) — and is NOT used for key-purpose failures.)

9. Check the [Transport revocation](#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](#webhook-replay-dedup-sizing). Reject with `webhook_signature_rate_abuse` if exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing.

10. 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](#adcp-rfc-9421-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).

11. Recompute `content-digest` from the received body bytes and compare (`webhook_signature_digest_mismatch` on mismatch). REQUIRED — no policy branch.

12. Check the nonce against the replay cache. Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`webhook_signature_replayed`).

13. **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.

14. **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](#legacy-hmac-sha256-fallback-deprecated-removed-in-40)) 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](#webhook-replay-dedup-sizing)) are designed to detect. The invariant mirrors the [analogous request-signing rule](#verifier-checklist-requests) (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](#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 `keyid`s per unit time (e.g., a 5-minute rolling count of distinct `keyid`s 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](/docs/building/by-layer/L1/webhook-verifier-tuning). 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:

1. **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
2. **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](#adcp-rfc-9421-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](#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](#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](/docs/building/by-layer/L3/webhooks#reliability) section for dedup scope under mixed-mode delivery.

##### Webhook error taxonomy

Codes parallel the [request-signing error taxonomy](#transport-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`, `request-signing`}, absent, or `key_ops` lacks `verify` | `webhook_signature_key_purpose_invalid`   |
| `keyid` ∈ `revoked_kids`                                                                     | `webhook_signature_key_revoked`           |
| Revocation list not refreshed within grace                                                   | `webhook_signature_revocation_stale`      |
| Cryptographic verification failed                                                            | `webhook_signature_invalid`               |
| `content-digest` mismatch                                                                    | `webhook_signature_digest_mismatch`       |
| Body contains duplicate object keys (parser-differential attack class)                       | `webhook_body_malformed`                  |
| `@authority` does not match signed `@target-uri` authority component (cross-vhost replay)    | `webhook_target_uri_malformed`            |
| Nonce already seen within window                                                             | `webhook_signature_replayed`              |
| Per-keyid replay cache exceeded cap                                                          | `webhook_signature_rate_abuse`            |
| Registered auth mode does not match signature mode on received webhook                       | `webhook_mode_mismatch`                   |

**Retry semantics for verification failures.** At-least-once delivery tells senders to retry on any non-2xx response, but a verification failure is not a transient error — the signature bytes and request context arrive identically on every retry, so every retry fails identically. Senders MUST treat a `401` response carrying `WWW-Authenticate: Signature error="webhook_*"` (any code defined in the taxonomy above, including `webhook_signature_*`, `webhook_target_uri_malformed`, and `webhook_mode_mismatch`) as a terminal failure for that specific delivery attempt: stop retrying the current event, log the failure with the error code for operator attention, and continue the normal retry queue for subsequent events. Senders SHOULD route sustained `webhook_*` error rates above an operator-defined threshold to incident response rather than continuing to emit them — persistent signature, authority, or mode failures indicate a key-rotation coordination problem, a misconfigured verifier, or a compromise, all of which need human action. Receivers MUST NOT silently discard these failures; surfacing them in operator logs is part of the security posture.

**Editor note on future additions.** The wildcard `webhook_*` terminal-failure classification above is an eager sweep: any new code added to the taxonomy inherits terminal-per-delivery semantics without individual review. Editors adding a new `webhook_*` code that SHOULD be retryable (e.g., a future transient-infrastructure signal) MUST update this paragraph to carve out the exception at the point of addition — do not rely on the pattern match to remain safe for codes not yet defined.

##### Webhook migration timeline

| Phase  | Behavior                                                                                                                                                                                                                                                                                    |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 3.0 GA | 9421 webhook signing is baseline for any seller that emits webhooks. Legacy HMAC-SHA256 fallback available when buyer populates `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`; sellers MAY decline to support it. |
| 3.x    | HMAC fallback is deprecated. Sellers SHOULD log warnings when selected. SDKs SHOULD surface a deprecation notice to buyers that still configure `authentication`.                                                                                                                           |
| 4.0    | `authentication` on `push_notification_config` and `accounts[].notification_configs[]` is removed from the schema. 9421 webhook signing is the only supported path.                                                                                                                         |

#### TMP cross-reference

**TMP keys MUST declare a distinct `adcp_use` value** (or omit it entirely) so verifiers reject them for request signing via step 8. Publishing TMP keys at the same `jwks_uri` as request-signing and webhook-signing keys is permitted and encouraged — one publication pattern, five signing systems, each `kid`-scoped:

* governance JWS — `adcp_use: "governance-signing"`
* request signing (RFC 9421) — `adcp_use: "request-signing"` (also signs webhooks; see [Webhook callbacks](#webhook-callbacks))
* webhook signing (RFC 9421) — uses the `request-signing` key; the legacy `adcp_use: "webhook-signing"` value is **deprecated** (still accepted, pending removal — see follow-up issue in the deprecation note)
* designated-task response-payload JWS — `adcp_use: "response-signing"` (see [Designated-task payload-envelope response signing](#designated-task-response-signing) above)
* TMP envelope — TMP's own future `adcp_use` value

Cross-purpose reuse is prevented automatically because every verifier enforces an exact `adcp_use` match on its own profile.

Trusted Match Protocol signs match-time requests with its own Ed25519 envelope. TMP's per-request budget (sample-verify at \~5%) is too tight for full RFC 9421 verification on every call. **TMP signing is out of scope for this section**; this profile only constrains how TMP keys are published alongside request-signing keys on the same JWKS.

#### Transport migration timeline

AdCP 4.0 is the next breaking-changes accumulation window. Mandatory request signing for spend-committing operations is one of its floor requirements — the minimum security bar for AdCP 4.0 spend traffic — not the sole headline feature. Other v4.0 changes will accumulate on the [roadmap](/docs/reference/roadmap#v40-planned).

| 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/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/).

```ts theme={null}
import { createRemoteJWKSet } from "jose";

class RequestSignatureError extends Error {
  constructor(public code: string) { super(code); }
}

const ALLOWED_ALGS = new Set(["ed25519", "ecdsa-p256-sha256"]);
const REQUIRED_TAG = "adcp/request-signing/v1";
const REQUIRED_COMPONENTS = new Set(["@method", "@target-uri", "@authority"]);
const REQUIRED_PARAMS = ["created", "expires", "nonce", "keyid", "alg", "tag"] as const;

export async function verifyAdcpRequestSignature(req: Request, ctx: {
  operationName: string;
  requiredFor: Set<string>;
  contentDigestPolicy: "required" | "forbidden" | "either";
  resolveJwk: (keyid: string) => Promise<{ jwk: unknown; agentUrl: string }>; // throws _key_unknown after refetch
  isKeyRevoked: (keyid: string) => Promise<boolean>;
  isRevocationStale: () => Promise<boolean>;
  isKeyidAtCapacity: (keyid: string) => Promise<boolean>;
  isReplayed: (keyid: string, nonce: string) => Promise<boolean>;
  recordNonce: (keyid: string, nonce: string, ttlSeconds: number) => Promise<void>;
  verify9421: (req: Request, jwk: unknown, covered: string[]) => Promise<void>; // throws on signature or digest failure
  parseSignatureInput: (header: string) => {
    keyid?: string; alg?: string; created?: number; expires?: number;
    nonce?: string; tag?: string; components: string[];
  };
}) {
  const sigInput = req.headers.get("signature-input");

  // Pre-check: required_for / downgrade protection.
  if (!sigInput) {
    if (ctx.requiredFor.has(ctx.operationName)) throw new RequestSignatureError("request_signature_required");
    return; // operation doesn't require a signature; verify nothing.
  }

  let parsed;
  try { parsed = ctx.parseSignatureInput(sigInput); }
  catch { throw new RequestSignatureError("request_signature_header_malformed"); }

  // 2: presence
  for (const p of REQUIRED_PARAMS) {
    if ((parsed as any)[p] == null) throw new RequestSignatureError("request_signature_params_incomplete");
  }
  // 3: tag
  if (parsed.tag !== REQUIRED_TAG) throw new RequestSignatureError("request_signature_tag_invalid");
  // 4: alg
  if (!ALLOWED_ALGS.has(parsed.alg!)) throw new RequestSignatureError("request_signature_alg_not_allowed");
  // 5: window (including expires > created)
  const now = Math.floor(Date.now() / 1000);
  if (parsed.expires! <= parsed.created! ||
      parsed.created! > now + 60 ||
      parsed.expires! < now - 60 ||
      parsed.expires! - parsed.created! > 300) {
    throw new RequestSignatureError("request_signature_window_invalid");
  }
  // 6: components
  for (const c of REQUIRED_COMPONENTS) {
    if (!parsed.components.includes(c)) throw new RequestSignatureError("request_signature_components_incomplete");
  }
  const coversCd = parsed.components.includes("content-digest");
  if (ctx.contentDigestPolicy === "required" && !coversCd) {
    throw new RequestSignatureError("request_signature_components_incomplete");
  }
  if (ctx.contentDigestPolicy === "forbidden" && coversCd) {
    throw new RequestSignatureError("request_signature_components_unexpected");
  }
  // 7: JWK resolution
  const { jwk } = await ctx.resolveJwk(parsed.keyid!); // throws _key_unknown
  // 8: key purpose
  const j = jwk as any;
  if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.example_use !== "request-signing") {
    throw new RequestSignatureError("request_signature_key_purpose_invalid");
  }
  // 9: revocation (BEFORE crypto verify)
  if (await ctx.isRevocationStale()) throw new RequestSignatureError("request_signature_revocation_stale");
  if (await ctx.isKeyRevoked(parsed.keyid!)) throw new RequestSignatureError("request_signature_key_revoked");
  // 9a: per-keyid cap (BEFORE crypto verify) — prevents amplified crypto work by abusive/misconfigured signer.
  if (await ctx.isKeyidAtCapacity(parsed.keyid!)) {
    throw new RequestSignatureError("request_signature_rate_abuse");
  }
  // 10 + 11: crypto verify, content-digest recompute — both inside verify9421.
  try { await ctx.verify9421(req, jwk, parsed.components); }
  catch (e: any) {
    if (e?.code === "digest_mismatch") throw new RequestSignatureError("request_signature_digest_mismatch");
    throw new RequestSignatureError("request_signature_invalid");
  }
  // 12: replay check
  if (await ctx.isReplayed(parsed.keyid!, parsed.nonce!)) {
    throw new RequestSignatureError("request_signature_replayed");
  }
  // 13: replay insert (only after all checks pass)
  await ctx.recordNonce(parsed.keyid!, parsed.nonce!, (parsed.expires! - now) + 60);
}
```

### Budget Validation

Validate budgets before committing:

```javascript theme={null}
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.

### Inbound server-side headers

```javascript theme={null}
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](#webhook-url-validation-ssrf) — 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](#buyer-identity-resolution) already says "one redirect (`authoritative_location` or `house` variant), no chains," and the initial `/.well-known/adagents.json` fetch follows same-registrable-domain redirects only (apex↔www, HTTPS-preserving, ≤ 3 hops, anchored on the originally-requested domain) — everywhere else, zero. The `adagents.json` `authoritative_location` dereference is "everywhere else": zero redirects.
* 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](/docs/building/by-layer/L2/authentication#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](#buyer-identity-resolution) — header fields can be injected across misconfigured proxies.
* The terminating edge (load balancer, mesh sidecar) MUST forward the verified certificate identity to the AdCP server over an in-cluster channel the server can authenticate. Unauthenticated sidecar headers are a bypass — deploy mTLS end-to-end, or pin the in-cluster channel.
* Client certificates MUST be checked against a CRL or OCSP responder operated by the operator. "Issued by us" is not the same as "still valid."

### Private-network and metadata protection

This section's transport controls do not substitute for the [SSRF controls](#webhook-url-validation-ssrf) 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](#request-signing) and [webhook callbacks](#webhook-callbacks)) — TLS protects the wire, not the payload after a compromised intermediary.
* **Governance attestation** ([signed governance context](#signed-governance-context)) — TLS does not tell the seller whether the buyer's governance agent authorized this spend.
* **Idempotency** ([request safety](#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.

## Input Validation

### Request Validation

Validate all user-provided input:

```javascript theme={null}
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:

```javascript theme={null}
// 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:

```javascript theme={null}
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)

* [ ] Implement strong authentication (OAuth 2.0, API keys, or mTLS)
* [ ] Enforce agent and account isolation in all database queries
* [ ] Implement idempotency for financial operations
* [ ] Validate all input with strict schema validation
* [ ] Use TLS 1.3+ for all communications
* [ ] Verify webhook signatures cryptographically
* [ ] Log all security events immutably

### For Buyer Agents (AdCP Clients)

* [ ] Store credentials in secure key management system
* [ ] Rotate credentials every 90 days
* [ ] Use HTTPS for all AdCP communications
* [ ] Validate responses from publishers
* [ ] Implement alerts for unusual spending patterns

### For Orchestrators (Multi-Agent, Multi-Account)

* [ ] Store each agent's credentials separately (encrypted)
* [ ] Enforce agent and account filtering in ALL queries
* [ ] Use row-level security in databases
* [ ] Log all operations with agent and account identity
* [ ] Implement per-agent rate limiting

## Next Steps

* **Security Model**: See [Security Model](/docs/building/concepts/security-model) for the threat model and the five-layer defense narrative this reference implements
* **Webhooks**: See [Webhooks](/docs/building/by-layer/L3/webhooks) for webhook security patterns
* **Error Handling**: See [Error Handling](/docs/building/by-layer/L3/error-handling) for authentication errors
* **Orchestrator Design**: See [Orchestrator Design](/docs/building/operating/orchestrator-design) for multi-tenant security
