> ## Documentation Index
> Fetch the complete documentation index at: https://docs.adcontextprotocol.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Request Signing Guide

> Step-by-step guide to RFC 9421 request signing in AdCP: key generation, JWKS publication, brand.json setup, client-side signing, server-side verification, webhook signing, key rotation, and conformance testing.

AdCP supports [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421) for cryptographic request authentication. A buyer signs outbound requests so the seller can verify who sent them and that the payload wasn't tampered with. A seller signs outbound webhooks so the buyer can verify authenticity.

Signing remains **optional through AdCP 3.2** and becomes **mandatory in AdCP 4.0** for all spend-committing operations. Starting in 3.2, however, every accepted signature on a request with a body must cover `Content-Digest`. Agents that don't support signing may continue using their existing authentication path.

> [!WARNING]
>
> **Two signing profiles in AdCP 3.3.** This guide's steps cover the **AdCP RFC 9421 profile** (`adcp/request-signing/v1`, keys published through brand.json). That profile is **deprecated in 3.3**: it keeps working through 3.x and is removed in 4.0. The experimental **Web Bot Auth profile** (`web-bot-auth`) replaces it. New implementations should plan for Web Bot Auth; see [Web Bot Auth profile](#web-bot-auth-profile-33-experimental) below. Its SDK helpers are in progress.

> [!NOTE]
>
> This is the practical implementation guide. For the normative specification — covered components, canonicalization rules, the full verifier checklist, replay dedup sizing, and the complete error taxonomy — see [Security: Signed Requests](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#signed-requests-transport-layer).

> [!NOTE]
>
> Code examples below use **JavaScript/TypeScript**, **Python**, and **Go** SDK helpers in tabs. All three SDKs implement the same RFC 9421 profile against the same conformance vectors — the API surface differs but the wire output is identical. If your language isn't listed, the [conformance vectors](#testing) and the [normative spec](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#signed-requests-transport-layer) are language-agnostic.

## When you need this

| You are a... | You need to... | Why |
| - | - | - |
| **Buyer** (calls seller tools) | Sign outbound requests | Sellers may require proof the request came from you |
| **Buyer** (receives webhooks) | Verify inbound webhook signatures | Confirm the webhook came from the seller |
| **Seller** (receives tool calls) | Verify inbound request signatures | Confirm the buyer is who they claim to be |
| **Seller** (sends webhooks) | Sign outbound webhooks | Let buyers verify webhook authenticity |
| **Orchestrator** (proxies to sellers) | Sign outbound requests + verify inbound webhooks | You're the buyer from the seller's perspective |

## Key concepts

### Signature coverage

The AdCP signing profile covers these request components:

* `@method` — HTTP method
* `@target-uri` — full canonicalized request URL
* `@authority` — lowercased host header
* `content-type` — media type
* `content-digest` — SHA-256 or SHA-512 hash of the exact request body bytes; REQUIRED for every body-bearing request signed under the 3.2 profile

If any covered component changes after signing, verification fails.

For DNS hosts, signers and verifiers MUST remove one trailing root-label dot
after IDNA ToASCII processing and before deriving both `@target-uri` and
`@authority`. Thus `https://example.com./p` signs as
`https://example.com/p` with authority `example.com`; U-label and A-label IDN
forms produce the same bytes. Multiple trailing dots are malformed and MUST be
rejected. This codifies the shipping SDK behavior under
[DR-0008](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0008-sdk-canon-for-wire-contracts.md).

### Key separation

Every agent publishes signing keys with a distinct `kid` and `adcp_use` tag:

* `adcp_use: "request-signing"` — for signing outbound tool calls and outbound webhooks
* `adcp_use: "webhook-signing"` — deprecated; still accepted on the webhook path for backward compatibility

For blast-radius isolation, publish a second `request-signing` key under a separate `kid` for webhook delivery rather than reusing the same key material for tool calls and webhooks.

### Discovery chain

Verifiers find your public key starting from your agent URL:

```
Your agent URL (e.g., https://agent.example.com/mcp)
  -> get_adcp_capabilities                   # protocol call to your agent
     -> identity.brand_json_url              # pointer to your operator brand.json
        -> agents[] entry matching your URL  # operator attests the agent
           -> jwks_uri                       # pointer to your key store
              -> JWKS                        # public keys, matched by keyid
```

The verifier calls your agent, not your domain's `/.well-known/brand.json`. Many brand.json files can list the same agent URL, so only the one your agent points to counts. The domain-derived `/.well-known/brand.json` lookup is a 3.x fallback for webhooks from older sellers, never for request signatures. The full algorithm and its error codes are in [Discovering an agent's signing keys via `brand_json_url`](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url).

The TypeScript SDK's `BrandJsonJwksResolver` (see [JWKS resolver options](#jwks-resolver-options)) runs this chain with caching and refresh. It calls `get_adcp_capabilities` on the agent URL, including when the seller already holds an onboarding record for the agent.

## Step 1: Generate a signing key

### CLI

```bash theme={null}
adcp signing generate-key --alg ed25519 --kid my-agent-2026 \
  --private-out ./private.jwk --public-out ./public-jwks.json
```

This generates an Ed25519 keypair and writes:

* `private.jwk` — the private key (JWK with `d` field). Keep this secret.
* `public-jwks.json` — the public key in JWKS format. Publish this.

### Programmatic

**JavaScript/TypeScript**

```typescript theme={null}
import { generateKeyPair, exportJWK } from 'jose';

const { publicKey, privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519' });
const publicJwk = await exportJWK(publicKey);
const privateJwk = await exportJWK(privateKey);

const kid = 'my-agent-2026';
publicJwk.kid = kid;
publicJwk.use = 'sig';
publicJwk.key_ops = ['verify'];
publicJwk.adcp_use = 'request-signing';
```

**Python**

```python theme={null}
from adcp.signing import generate_signing_keypair

# CLI equivalence: adcp-keygen --alg ed25519 --purpose request-signing --kid my-agent-2026
pem_bytes, public_jwk = generate_signing_keypair(
    alg="ed25519",
    purpose="request-signing",
    kid="my-agent-2026",
)
# pem_bytes — write to disk with mode 0600 and O_EXCL, or pass to a secret manager.
# public_jwk — publish in your JWKS endpoint. Already includes kid, use, key_ops, and adcp_use.
```

**Go**

```go theme={null}
import "github.com/adcontextprotocol/adcp-go/adcp/signing"

res, err := signing.GenerateKeyForProfile(
    signing.AlgEd25519,
    "my-agent-2026",
    signing.ProfileRequestSigning,
)
if err != nil { /* handle */ }
// res.PrivateKeyPEM — write to disk with mode 0600, or pass to a secret manager.
// res.PublicJWK    — serialize and publish in your JWKS endpoint. AdCP-required
//                    fields (kid, kty, crv, alg, use, key_ops, adcp_use) are set.
```

Use `signing.ProfileWebhookSigning` for the webhook RFC 9421 profile. Publish the corresponding public key with `adcp_use: "request-signing"`; if you want webhook-only key material, use a separate `kid`.

### Supported algorithms

| Algorithm | `alg` value | Key type | Notes |
| - | - | - | - |
| Ed25519 | `ed25519` (RFC 9421) / `EdDSA` (JWK) | `OKP` / `Ed25519` | Preferred. Fast, small signatures. |
| ECDSA P-256 | `ecdsa-p256-sha256` (RFC 9421) / `ES256` (JWK) | `EC` / `P-256` | Edge-runtime friendly (Cloudflare Workers, Vercel Edge). |

> [!WARNING]
>
> The algorithm name differs between the JWK entry (`"alg": "EdDSA"`) and the RFC 9421 `Signature-Input` parameter (`alg="ed25519"`). See the [algorithm naming table](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#adcp-rfc-9421-profile) in the spec.

### Storing the private key

Pick the strongest option your runtime supports. From most to least secure:

* **Cloud KMS** (GCP Cloud KMS, AWS KMS, Azure Key Vault): the private key is generated inside the HSM and never leaves it. Signing is performed by calling the KMS API; you only ever hold the JWK reference, not the key bytes. The TypeScript SDK exposes `createKmsSigner` for GCP Cloud KMS — see [`@adcp/client/signing/kms`](https://github.com/adcontextprotocol/adcp-client/tree/main/src/lib/signing/kms). Recommended for any agent handling spend-committing operations.
* **Secret manager** (GCP Secret Manager, AWS Secrets Manager, HashiCorp Vault): load at boot, keep in memory for the process lifetime. Easier than KMS but the key material lives in your process — leaks via memory dumps, logging, or compromised dependencies.
* **Environment variable**: `ADCP_SIGNING_PRIVATE_KEY='{"kid":"...","kty":"OKP",...}'`. Acceptable for dev and small deployments. Same memory-resident risk as a secret manager.
* **File**: development only. Never commit to version control. Use mode `0600` and `O_EXCL` so an existing file is never overwritten — `Path.write_bytes` inherits the process umask (often `0644`, world-readable) and is unsafe for private-key material.

> [!NOTE]
>
> Once you choose KMS, signing latency rises (one round-trip to the HSM per request). Profile under load before committing — the TypeScript and Python SDKs cache JWK metadata aggressively and can sustain hundreds of signs/sec against GCP KMS in our internal tests, but your numbers depend on region and concurrency.

## Step 2: Publish your public keys

### JWKS endpoint

Serve a JSON Web Key Set at a stable HTTPS URL (defaults to `/.well-known/jwks.json`):

```json theme={null}
{
  "keys": [
    {
      "kid": "my-agent-2026",
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "<base64url-encoded-public-key>",
      "use": "sig",
      "key_ops": ["verify"],
      "adcp_use": "request-signing"
    }
  ]
}
```

Only public keys go here — no `d` field. Set `Cache-Control: max-age=3600` or similar. If you use separate key material for webhook delivery, publish a second `request-signing` JWK with a distinct `kid`. Deprecated `webhook-signing` JWKs remain accepted on the webhook path for backward compatibility.

### brand.json

Serve at `/.well-known/brand.json` on your brand domain. The `agents[].url` must be your exact agent URL, and the `jwks_uri` is how verifiers find your keys:

```json theme={null}
{
  "name": "My Company",
  "domain": "example.com",
  "agents": [
    {
      "type": "buying",
      "id": "media_buyer",
      "url": "https://agent.example.com",
      "jwks_uri": "https://agent.example.com/.well-known/jwks.json"
    }
  ]
}
```

### Capabilities pointer

Your agent's `get_adcp_capabilities` response carries `identity.brand_json_url`, the HTTPS URL of the brand.json above. The host of that URL must share a registrable domain (eTLD+1) with your agent URL, unless the brand.json delegates your agent's domain through `authorized_operators[]`.

```json theme={null}
{
  "identity": {
    "brand_json_url": "https://example.com/.well-known/brand.json"
  }
}
```

> [!NOTE]
>
> **Buyers that only call sellers still answer `get_adcp_capabilities`.** A verifier starts from your agent URL, so a buyer that signs requests must answer `get_adcp_capabilities` there, over MCP or A2A, even if it hosts no other tools. A seller's onboarding record doesn't remove this. The record must be re-confirmed against your `identity.brand_json_url` within the brand.json cache lifetime (see [Agent resolution](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#agent-resolution)). A buyer that serves nothing at its agent URL fails verification with `request_signature_capabilities_unreachable`.
>
> Verifiers read only `identity` from this response. The response schema still requires `adcp` and a non-empty `supported_protocols`, whose values commit the agent to the matching seller-side storyboards, and there is no buyer-only value yet. A path for signers with no capabilities endpoint, and a response shape for buyers that serve nothing else, are under discussion in [#6105](https://github.com/adcontextprotocol/adcp/issues/6105).

## Step 3: Sign outbound requests (buyer / orchestrator)

### Wrapping fetch / HTTP client

**JavaScript/TypeScript**

`createSigningFetch` wraps any `fetch`-compatible function to sign outbound requests automatically:

```typescript theme={null}
import { createSigningFetch } from '@adcp/client/signing';

const privateJwk = JSON.parse(process.env.ADCP_SIGNING_PRIVATE_KEY);

const signingFetch = createSigningFetch(fetch, {
  keyid: 'my-agent-2026',
  alg: 'ed25519',
  privateKey: privateJwk,
});

// Use signingFetch anywhere you'd use fetch.
// Signature, Signature-Input, and Content-Digest headers are added automatically.
await signingFetch('https://seller.example.com/mcp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
});
```

**Python**

The Python SDK auto-signs every outbound request when `signing` is configured on `ADCPClient`:

```python theme={null}
from adcp import ADCPClient
from adcp.signing import SigningConfig, load_private_key_pem

private_key = load_private_key_pem(open("private-key.pem", "rb").read())

client = ADCPClient(
    base_url="https://seller.example.com/mcp",
    signing=SigningConfig(
        key_id="my-agent-2026",
        alg="ed25519",
        private_key=private_key,
        cover_content_digest=True,
    ),
)
# Every request the client sends carries Signature, Signature-Input, and
# Content-Digest headers.
```

For lower-level control (e.g., signing an arbitrary `httpx.Request` outside the client), call `sign_request` directly:

```python theme={null}
from adcp.signing import sign_request

signed = sign_request(
    method="POST",
    url="https://seller.example.com/mcp",
    headers={"Content-Type": "application/json"},
    body=request_body_bytes,
    private_key=private_key,
    key_id="my-agent-2026",
    alg="ed25519",
    cover_content_digest=True,
)
# signed.as_dict() returns the headers to attach to the outgoing request.
```

**Go**

The Go SDK exposes a `Signer` you wrap your `http.Client` transport with:

```go theme={null}
import (
    "net/http"
    "github.com/adcontextprotocol/adcp-go/adcp/signing"
)

priv, _, _ := signing.LoadPrivateKey(pemBytes)
signer, _ := signing.NewSigner(signing.SignerOptions{
    KeyID:      "my-agent-2026",
    Algorithm:  signing.AlgEd25519,
    PrivateKey: priv,
})

client := &http.Client{
    Transport: signer.RoundTripper(http.DefaultTransport, true /* cover content-digest */),
}

req, _ := http.NewRequest("POST", "https://seller.example.com/mcp", body)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
// Signature, Signature-Input, and Content-Digest are added by the transport.
```

For one-off signing without the transport, call `signer.SignRequest(req, signing.SignOptions{CoverContentDigest: true})` directly on the request.

### Capability-aware signing

`buildAgentSigningFetch` checks whether the target seller supports `signed-requests` and only signs when supported. This is the recommended approach for production:

```typescript theme={null}
import { buildAgentSigningFetch, CapabilityCache } from '@adcp/client/signing/client';

const capabilityCache = new CapabilityCache();

const signingFetch = buildAgentSigningFetch({
  upstream: fetch,
  signing: {
    kid: 'my-agent-2026',
    alg: 'ed25519',
    private_key: privateJwk,
    agent_url: 'https://agent.example.com',
    sign_supported: true,
  },
  getCapability: () => capabilityCache.get('https://seller.example.com'),
});
```

This avoids sending signatures to agents that don't expect them and caches capability lookups.

## Step 4: Verify inbound signatures (seller)

### Framework middleware

**JavaScript/TypeScript**

For raw Express routes, mount `createExpressVerifier` after a raw-body middleware. Use `mcpToolNameResolver` for the `resolveOperation` callback — it parses the JSON-RPC envelope and returns the MCP tool name:

```typescript theme={null}
import {
  createExpressVerifier,
  StaticJwksResolver,
  InMemoryReplayStore,
  InMemoryRevocationStore,
} from '@adcp/client/signing';
import { mcpToolNameResolver } from '@adcp/client/server';

app.post(
  '/mcp',
  rawBodyMiddleware(), // req.rawBody must hold the byte-exact body
  createExpressVerifier({
    capability: {
      supported: true,
      covers_content_digest: 'required',
      required_for: ['create_media_buy', 'update_media_buy'],
    },
    jwks: new StaticJwksResolver(buyerPublicKeys),
    replayStore: new InMemoryReplayStore(),
    revocationStore: new InMemoryRevocationStore(),
    resolveOperation: mcpToolNameResolver,
  }),
  handler
);
// On verify: req.verifiedSigner = { keyid, agent_url?, verified_at }.
// On reject: 401 with WWW-Authenticate: Signature error="<code>".
```

**Python**

The Python SDK ships framework wrappers for Flask and Starlette/FastAPI. Both call into the same `verify_request_signature` and raise `SignatureVerificationError` on rejection. Map that to a 401 with `unauthorized_response_headers`.

A JWKS resolver covers one JWKS, so it's bound to one buyer agent. `CachingJwksResolver` takes that agent's `jwks_uri`. A seller with several buyers keeps one resolver per onboarded agent and picks it by `keyid`. The request carries no agent URL, so the `keyid` → agent index is built from onboarding records (see [Seller walkthrough: buyer-declared accounts](/dist/docs/3.3.0-beta.0/building/by-layer/L2/accounts-and-agents#seller-walkthrough-buyer-declared-accounts)). `VerifyOptions` also needs the per-request `now` and `operation`, so build it for each request and reuse one replay store:

```python test=false theme={null}
import json
import time

from fastapi import FastAPI, HTTPException, Request
from adcp.signing import (
    AgentResolverError,
    InMemoryReplayStore,
    StaticJwksResolver,
    VerifierCapability,
    VerifyOptions,
    async_resolve_agent,
    check_key_origin_consistency,
    parse_signature_input_header,
)
from adcp.signing.errors import SignatureVerificationError
from adcp.signing.middleware import (
    unauthorized_response_headers,
    verify_starlette_request,
)

app = FastAPI()

SIGNING_PROFILE = "3.2"  # the profile this endpoint advertises; never read it from the request
SIG_LABEL = "sig1"
CAPABILITY = VerifierCapability(
    supported=True,
    covers_content_digest="required",
    supported_for=frozenset({"create_media_buy", "update_media_buy"}),
    required_for=frozenset({"create_media_buy", "update_media_buy"}),
)
REPLAY_STORE = InMemoryReplayStore()  # one per process; use a shared store across replicas
NO_KEYS = StaticJwksResolver({"keys": []})  # unknown keyid -> request_signature_key_unknown
AGENT_INDEX: dict[str, tuple[str, StaticJwksResolver]] = {}  # kid -> (agent URL, its keys)


async def rebuild_agent_index(onboarded_agent_urls: list[str]) -> None:
    """Run at startup and at least once per revocation polling interval.

    Agent URLs come from your onboarding records, never from a request.
    """
    global AGENT_INDEX
    owners: dict[str, list[tuple[str, StaticJwksResolver]]] = {}
    for url in onboarded_agent_urls:
        try:
            res = await async_resolve_agent(url, agent_type="buying")
            check_key_origin_consistency(
                jwks_uri=res.jwks_uri, key_origins=res.key_origins, purpose="request_signing"
            )
        except (AgentResolverError, SignatureVerificationError):
            continue  # delisted or misconfigured: the agent drops out of the index
        agent = (res.agent_entry["url"], StaticJwksResolver(res.jwks))
        for key in res.jwks.get("keys", []):
            owners.setdefault(key.get("kid"), []).append(agent)
    # A kid published by more than one agent maps to neither: both fail closed.
    AGENT_INDEX = {kid: agents[0] for kid, agents in owners.items() if kid and len(agents) == 1}


def _reject_duplicate_keys(pairs):
    obj = {}
    for key, value in pairs:
        if key in obj:
            raise ValueError(f"duplicate key {key!r}")
        obj[key] = value
    return obj


def parse_envelope(body: bytes) -> dict | None:
    """Parse once, strictly (checklist step 14). None means malformed."""
    try:
        envelope = json.loads(body, object_pairs_hook=_reject_duplicate_keys)
    except ValueError:
        return None
    return envelope if isinstance(envelope, dict) else None


def mcp_operation(envelope: dict | None) -> str:
    if envelope is None or envelope.get("method") != "tools/call":
        return ""  # JSON-RPC protocol methods are matched by protocol_methods_*, not here
    params = envelope.get("params")
    name = params.get("name") if isinstance(params, dict) else None
    return name if isinstance(name, str) else ""


def signature_keyid(headers) -> str | None:
    try:
        label = parse_signature_input_header(headers.get("signature-input", ""))[SIG_LABEL]
    except (ValueError, KeyError):
        return None  # the verifier reports the precise header error
    return str(label.params.get("keyid"))


@app.post("/mcp")
async def mcp(request: Request):
    body = await request.body()
    envelope = parse_envelope(body)
    operation = mcp_operation(envelope)

    if "signature" not in request.headers and "signature-input" not in request.headers:
        if envelope is None:
            raise HTTPException(status_code=400, detail="malformed JSON-RPC body")
        principal = authenticate_fallback(request, envelope, operation)  # bearer / API key / mTLS
        if principal is None and operation in CAPABILITY.required_for:
            raise HTTPException(
                status_code=401,
                detail="request_signature_required",
                headers={"WWW-Authenticate": 'Signature error="request_signature_required"'},
            )
        if principal is None:
            return auth_error(request)  # AUTH_MISSING, or AUTH_INVALID for a rejected credential
        return await handle_mcp(envelope, principal)

    agent_url, jwks = AGENT_INDEX.get(signature_keyid(request.headers), (None, NO_KEYS))
    options = VerifyOptions(
        now=time.time(),
        capability=CAPABILITY,
        operation=operation,
        jwks_resolver=jwks,
        agent_url=agent_url,  # copied onto signer.agent_url
        replay_store=REPLAY_STORE,
        label=SIG_LABEL,
        signing_profile_version=SIGNING_PROFILE,
    )
    try:
        signer = await verify_starlette_request(request, options=options)
        if envelope is None:
            # Verified first so step 13 burned the nonce; now reject the ambiguous body.
            raise SignatureVerificationError("request_body_malformed", step=14)
    except SignatureVerificationError as exc:
        raise HTTPException(
            status_code=401,
            detail=exc.code,
            headers=unauthorized_response_headers(exc),
        )
    # signer.key_id, signer.agent_url, signer.verified_at are available for audit.
    return await handle_mcp(envelope, signer)
```

`authenticate_fallback`, `auth_error`, and `handle_mcp` stand for your own code, which is why the block is marked `test=false`. Some points the example depends on:

* **Unsigned requests.** `authenticate_fallback` returns `None` for a credential you don't accept. It also has to refuse an operation where signing must be unconditional (the strict posture), and a payload that carries a webhook `authentication` block when you support signing. Those cases get `request_signature_required` too ([Composition with fallback authenticators](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#composition-with-fallback-authenticators)).
* **One strict parse.** The SDK verifier doesn't run the step-14 duplicate-key check, so the example parses the body once with duplicate keys rejected and hands `envelope` to `handle_mcp`. Business logic never parses the body again.
* **Key rotation.** A `kid` published between rebuilds fails with `request_signature_key_unknown` until the next rebuild, so rebuild at least as often as your JWKS cache TTL. Never fall back to trying every agent's keys.
* **Blocking fetches.** `StaticJwksResolver` never fetches, which keeps the request path off the network. `CachingJwksResolver(jwks_uri=...)` refetches on a `kid` miss, but it fetches synchronously. If you use it in an async handler, prime it at startup or call `verify_request_signature` through `run_in_threadpool`.

The SDK doesn't derive `signer.agent_url` from the request. It copies the `agent_url` you pass in `VerifyOptions`, and it's `None` when you pass none. `await verify_from_agent_url(request, agent_url, agent_type="buying", operation=..., capability=CAPABILITY)` runs the full discovery chain and fills `agent_url` from the matched `agents[]` entry. It fetches on every call, so pass an agent URL from your onboarding record, never one taken from the request. Always pass `capability`: the default is `VerifierCapability(supported=True)`, which accepts signatures without `content-digest`, and the helper can't set `signing_profile_version`, so it verifies under the 3.1 profile. Pass `revocation_checker` (for example, `CachingRevocationChecker`) if you track revoked keys.

For Flask: swap `verify_starlette_request` for `verify_flask_request` (sync). For non-Starlette ASGI frameworks, call `verify_request_signature` directly.

**Go**

Mount `signing.Middleware` in your `http.Handler` chain. The middleware verifies inbound signatures, populates a `VerifiedSigner` in the request context on success, and writes a `401` with `WWW-Authenticate: Signature error="<code>"` on failure:

```go theme={null}
import (
    "net/http"
    "github.com/adcontextprotocol/adcp-go/adcp/signing"
)

resolver := signing.NewCachingJWKSResolver()
replay := signing.NewMemoryReplayStore(0 /* default cap */)
revocation := signing.NewStaticRevocationSource(nil)

mw := signing.Middleware(signing.MiddlewareOptions{
    Resolver:            resolver,
    Replay:              replay,
    Revocation:          revocation,
    OperationResolver:   signing.DefaultOperationResolver, // /adcp/<op>
    ContentDigestPolicy: signing.DigestRequired,
    RequiredFor:         []string{"create_media_buy", "update_media_buy"},
})

http.Handle("/mcp", mw(handler))

// Inside handler:
func handler(w http.ResponseWriter, r *http.Request) {
    v := signing.VerifiedSignerFromContext(r.Context())
    if v == nil {
        // Operation not in RequiredFor and request was unsigned — proceed
        // with bearer auth or whatever fallback you've configured.
    }
    // v.KeyID, v.AgentURL, v.VerifiedAt, v.Algorithm — available for audit.
}
```

For MCP servers: replace `signing.DefaultOperationResolver` with a custom resolver that parses the JSON-RPC envelope and returns the MCP tool name (the equivalent of `mcpToolNameResolver` in the TS SDK).

### Composing signature + bearer auth with `requireAuthenticatedOrSigned`

`requireAuthenticatedOrSigned` bundles the full composition: presence-gated routing (signature auth when headers present, fallback otherwise) plus `requiredFor` enforcement — unauthenticated requests for signing-required operations get `401 request_signature_required` even when no credentials at all are supplied.

```typescript theme={null}
import {
  serve,
  verifyApiKey,
  verifySignatureAsAuthenticator,
  requireAuthenticatedOrSigned,
  mcpToolNameResolver,
  MUTATING_TASKS,
} from '@adcp/client/server';
import { BrandJsonJwksResolver, InMemoryReplayStore, InMemoryRevocationStore } from '@adcp/client/signing/server';

serve(createAgent, {
  authenticate: requireAuthenticatedOrSigned({
    signature: verifySignatureAsAuthenticator({
      capability: { supported: true, required_for: ['create_media_buy'], covers_content_digest: 'required' },
      jwks: new BrandJsonJwksResolver(),
      replayStore: new InMemoryReplayStore(),
      revocationStore: new InMemoryRevocationStore(),
      resolveOperation: mcpToolNameResolver,
    }),
    fallback: verifyApiKey({ keys: { 'sk_live_abc': { principal: 'acct_42' } } }),
    requiredFor: [...MUTATING_TASKS],
    resolveOperation: mcpToolNameResolver,
  }),
});
```

`MUTATING_TASKS` is the full list of spend-committing and state-changing operations exported from `@adcp/client/server` — use it rather than maintaining your own list.

### JWKS resolver options

| Resolver | Use case |
| - | - |
| `StaticJwksResolver` | Fixed set of known buyer keys. Good for dev/testing. |
| `HttpsJwksResolver` | Fetches JWKS from a URL with caching and refresh. |
| `BrandJsonJwksResolver` | Full discovery chain: brand.json → jwks\_uri → JWKS. Recommended for production. |

## Step 5: Verify inbound webhooks (buyer / orchestrator)

When sellers send webhooks, verify the signature to confirm authenticity. The webhook profile uses the same RFC 9421 mechanics as request signing but with `tag="adcp/webhook-signing/v1"` and `Content-Digest` always covered (no opt-out).

**JavaScript/TypeScript**

```typescript theme={null}
import {
  verifyWebhookSignature,
  BrandJsonJwksResolver,
  InMemoryReplayStore,
} from '@adcp/client/signing/server';

const jwks = new BrandJsonJwksResolver();
const replayStore = new InMemoryReplayStore();

app.post('/webhook', async (req, res) => {
  try {
    await verifyWebhookSignature(req, { jwks, replayStore });
  } catch {
    return res.status(401).json({ error: 'invalid webhook signature' });
  }

  // Process the verified webhook...
});
```

**Python**

```python theme={null}
from adcp.signing import (
    WebhookVerifyOptions,
    BrandJsonJwksResolver,
    InMemoryReplayStore,
    verify_webhook_signature,
)
from adcp.signing.errors import SignatureVerificationError

webhook_options = WebhookVerifyOptions(
    jwks_resolver=BrandJsonJwksResolver(),
    replay_store=InMemoryReplayStore(),
)

@app.post("/webhook")
async def webhook(request: Request):
    body = await request.body()
    try:
        sender = verify_webhook_signature(
            method=request.method,
            url=str(request.url),
            headers=dict(request.headers),
            body=body,
            options=webhook_options,
        )
    except SignatureVerificationError:
        raise HTTPException(status_code=401, detail="invalid webhook signature")
    # sender.key_id, sender.agent_url available for audit; process the webhook.
```

**Go**

```go theme={null}
import (
    "github.com/adcontextprotocol/adcp-go/adcp/signing"
)

// Mount the same Middleware on your webhook receiver, but configure it for
// the webhook profile — adcp_use="request-signing" (deprecated
// "webhook-signing" also accepted), Content-Digest required, no required_for
// gating (webhooks always carry signatures).
webhookMW := signing.Middleware(signing.MiddlewareOptions{
    Resolver:            signing.NewBrandJSONJWKSResolver(),
    Replay:              signing.NewMemoryReplayStore(0),
    Revocation:          signing.NewStaticRevocationSource(nil),
    Profile:             signing.ProfileWebhookSigning,
    ContentDigestPolicy: signing.DigestRequired,
})

http.Handle("/webhook", webhookMW(webhookHandler))

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    sender := signing.VerifiedSignerFromContext(r.Context())
    // sender.KeyID, sender.AgentURL — process the verified webhook.
}
```

## Step 6: Sign outbound webhooks (seller)

**JavaScript/TypeScript**

Pass a `signerKey` to `createAdcpServer` and the framework signs every outbound webhook automatically:

```typescript theme={null}
serve(() => createAdcpServer({
  name: 'My Seller',
  version: '1.0.0',
  webhooks: {
    signerKey: {
      keyid: 'my-seller-webhook-2026',
      alg: 'ed25519',
      privateKey: webhookPrivateJwk,
    },
  },
  mediaBuy: { /* ... */ },
}));
```

**Python**

Sign each outbound webhook with `sign_webhook` and attach the returned headers before sending:

```python theme={null}
from adcp.signing import sign_webhook, load_private_key_pem
import httpx, json

private_key = load_private_key_pem(open("webhook-private-key.pem", "rb").read())

async def post_webhook(url: str, payload: dict) -> None:
    body = json.dumps(payload).encode("utf-8")
    headers = {"Content-Type": "application/json"}
    signed = sign_webhook(
        method="POST",
        url=url,
        headers=headers,
        body=body,
        private_key=private_key,
        key_id="my-seller-webhook-2026",
        alg="ed25519",
    )
    headers.update(signed.as_dict())  # adds Signature, Signature-Input, Content-Digest
    async with httpx.AsyncClient() as client:
        await client.post(url, content=body, headers=headers)
```

**Go**

Configure a `Signer` with `ProfileWebhookSigning` and use it via `SignRequest` or its `RoundTripper`:

```go theme={null}
priv, _, _ := signing.LoadPrivateKey(webhookPemBytes)
webhookSigner, _ := signing.NewSigner(signing.SignerOptions{
    KeyID:      "my-seller-webhook-2026",
    Algorithm:  signing.AlgEd25519,
    PrivateKey: priv,
    Profile:    signing.ProfileWebhookSigning,
})

webhookClient := &http.Client{
    Transport: webhookSigner.RoundTripper(http.DefaultTransport, true /* always cover content-digest for webhooks */),
}
// Use webhookClient.Post / .Do to deliver webhooks; signatures are added automatically.
```

Publish the webhook signing public key as a JWK with `"adcp_use": "request-signing"`. The webhook verifier still accepts deprecated `"webhook-signing"` keys for backward compatibility, but new signers should use `request-signing`. If you want independent webhook rotation or blast-radius isolation, publish a separate `request-signing` JWK with a webhook-specific `kid`.

## Step 7: Declare the capability

If your seller verifies inbound signatures, declare `signed_requests` (alias `request_signing` in the on-wire schema) in your `get_adcp_capabilities` response so buyers know to sign:

For AdCP 3.2, `covers_content_digest: 'required'` is part of the signing profile rather than an operation-level choice. The operation lists still control where signing is supported, shadowed, or required; they never weaken body binding. Keep `either` or `forbidden` only on an explicitly configured 3.0/3.1 compatibility endpoint.

**JavaScript/TypeScript**

```typescript theme={null}
createAdcpServer({
  capabilities: {
    overrides: {
      signed_requests: {
        supported: true,
        required_for: ['create_media_buy', 'update_media_buy'],
        supported_for: ['sync_creatives', 'sync_audiences'],
        covers_content_digest: 'required',
      },
    },
  },
  mediaBuy: { /* ... */ },
});
```

**Python**

```python theme={null}
from adcp.server.responses import capabilities_response

class MySeller(ADCPHandler):
    async def get_adcp_capabilities(self, params, context=None):
        return capabilities_response(
            ["media_buy"],
            request_signing={
                "supported": True,
                "required_for": ["create_media_buy", "update_media_buy"],
                "supported_for": ["sync_creatives", "sync_audiences"],
                "covers_content_digest": "required",
            },
        )
```

**Go**

```go theme={null}
// In your get_adcp_capabilities handler, set the request_signing block on
// the response builder:
return adcp.CapabilitiesResponse(adcp.CapabilitiesData{
    SupportedProtocols: []string{"media-buy"},
    RequestSigning: &adcp.RequestSigningCapability{
        Supported:           true,
        RequiredFor:         []string{"create_media_buy", "update_media_buy"},
        SupportedFor:        []string{"sync_creatives", "sync_audiences"},
        CoversContentDigest: "required",
    },
}), nil
```

Buyers call `get_adcp_capabilities` and read `request_signing.required_for` and `supported_for` to know which operations you expect them to sign.

## Web Bot Auth profile (3.3, experimental)

The Web Bot Auth profile follows the IETF Web Bot Auth draft as written, so any conforming Web Bot Auth library can sign for AdCP. Its wire rules (signature parameters and algorithms, the key directory, nonce and replay, and failure responses) are on the [Web Bot Auth profile](/dist/docs/3.3.0-beta.0/building/by-layer/L1/wba-profile) page ([#7894](https://github.com/adcontextprotocol/adcp/pull/7894)), which also compares it with the RFC 9421 profile above. Identity, trust records, binding, and authorization are in [Web Bot Auth identities](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#web-bot-auth-identities). This section lists the setup steps only.

**Signer setup.** These steps let a seller verify which origin is calling:

1. Give each agent its own origin. An agency hosting several agents on one host moves each to a subdomain.
2. Serve the key directory at that origin, as [Key directories](/dist/docs/3.3.0-beta.0/building/by-layer/L1/wba-profile#key-directories) specifies. Regenerate it before its signatures expire.
3. Sign each request and webhook as [Profile rules](/dist/docs/3.3.0-beta.0/building/by-layer/L1/wba-profile#profile-rules) specifies, for sellers that advertise `web-bot-auth` in `request_signing.profiles` (added by [#7894](https://github.com/adcontextprotocol/adcp/pull/7894)).

**To be bound by a seller:**

4. Serve a signed revocation list at the origin, `/.well-known/governance-revocations.json` ([Transport revocation](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#transport-revocation)), even while it revokes nothing. Without it, a seller can verify you but cannot bind you for spend.
5. List the origin in `https://{registrable domain}/.well-known/trust.json` with its role (`adcp:buying` for a buyer). The record carries no keys.

**Verifier setup:**

1. Verify signatures with the profile's [Verification](/dist/docs/3.3.0-beta.0/building/by-layer/L1/wba-profile#verification) procedure, and answer failures as its [Error responses](/dist/docs/3.3.0-beta.0/building/by-layer/L1/wba-profile#error-responses) section says.
2. Treat a verified origin you have not bound as identity-established: no account access. Evaluate the operator's trust record only when you bind a principal, check a grant, or apply an `open` onboarding mode.
3. Once you have bound an origin, refuse RFC 9421 signatures for it.
4. Advertise `web-bot-auth` in `request_signing.profiles` (added by [#7894](https://github.com/adcontextprotocol/adcp/pull/7894)).

For the end-to-end buyer flow, see [A buyer agent onboards itself](/dist/docs/3.3.0-beta.0/accounts/buyer-agent-self-onboarding). For moving from the RFC 9421 profile, see the [agent identity migration guide](/dist/docs/3.3.0-beta.0/reference/migration/agent-identity-3-3).

## Key rotation

The JWKS endpoint supports multiple keys simultaneously for zero-downtime rotation:

1. Generate a new keypair with a new `kid`
2. Add the new public key to JWKS (both old and new are published)
3. Update signing configuration to use the new private key
4. After 24–48 hours, remove the old public key from JWKS

For emergency rotation (key compromise), add the old `kid` to `revoked_kids` in your revocation list and rotate to a new key immediately. See [Revocation](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#revocation) for the revocation list format.

## Testing

### Conformance vectors

The spec ships legacy-profile wire vectors, dedicated 3.2 wire vectors, and the 3.2 post-parse body-integrity reference-evaluator matrix at `/compliance/latest/test-vectors/request-signing/` (source at `static/compliance/source/test-vectors/request-signing/`). Select fixtures by `signing_profile_version`; the root positive and negative directories are 3.1 compatibility fixtures, not 3.2 conformance cases.

* **Legacy 3.1 positive vectors**: valid signed requests using the legacy binary encoding
* **Legacy 3.1 negative vectors**: invalid requests a 3.1 verifier must reject with the stated error code
* **Profile-3.2 wire vectors**: 3.2 versions of the legacy positive vectors and of the checklist step 2–12 negatives. They sign with `content-digest` covered (except the one negative that tests its absence) and assume a verifier advertising `covers_content_digest: "required"`. The set also includes rejections of malformed RFC 8941 `sf-binary` and malformed authorities.

```bash theme={null}
# Debug a single vector with the matching public key fixture.
# Use "latest" for convenience. Pin and record exact released values for both
# versions when you need to reproduce a verifier debugging run.
VECTOR_VERSION=latest
SDK_VERSION=latest
VECTOR_DIR="$(mktemp -d)"
trap 'rm -rf "$VECTOR_DIR"' EXIT
VECTOR_BASE="https://adcontextprotocol.org/compliance/${VECTOR_VERSION}/test-vectors/request-signing"

curl --fail --silent --show-error \
  --output "${VECTOR_DIR}/vector.json" \
  "${VECTOR_BASE}/positive/001-basic-post.json"
curl --fail --silent --show-error \
  --output "${VECTOR_DIR}/keys.json" \
  "${VECTOR_BASE}/keys.json"

npx "@adcp/sdk@${SDK_VERSION}" signing verify-vector \
  --vector "${VECTOR_DIR}/vector.json" \
  --keys "${VECTOR_DIR}/keys.json"
```

### Grade your verifier

```bash theme={null}
adcp grade request-signing https://agent.example.com/mcp --auth-token $TOKEN
```

### Error codes

When verification fails, return `401` with `WWW-Authenticate: Signature error="<code>"`.

The negative vectors grade `<code>` **byte-for-byte** against the vector's `expected_outcome.error_code`, so a rejection that is correct in substance still fails its vector if the code string differs. Take the code from the taxonomy rather than from memory.

For the codes — with retry semantics for each, and the rules for the `WWW-Authenticate` header itself — see [Transport error taxonomy](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#transport-error-taxonomy). Signing-key discovery via `brand_json_url` raises its own `request_signature_brand_*` and `request_signature_key_origin_*` codes, listed under [Discovering an agent's signing keys via `brand_json_url`](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url).

## Related

* [Security: Signed Requests](/dist/docs/3.3.0-beta.0/building/by-layer/L1/security#signed-requests-transport-layer) — normative spec with verifier checklist, canonicalization rules, and replay dedup sizing
* [Push Notifications](/dist/docs/3.3.0-beta.0/building/by-layer/L3/webhooks) — webhook setup including signature verification
* [Validate Your Agent](/dist/docs/3.3.0-beta.0/building/verification/validate-your-agent) — full compliance validation including signing conformance
* [Build an Agent](/dist/docs/3.3.0-beta.0/building/by-layer/L4/build-an-agent) — SDK setup and storyboard validation
* [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) — HTTP Message Signatures specification


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.