What does a signed request prove?
Most automated traffic identifies itself with a user agent string. That string is a claim, and copying it costs nothing. Signed requests change the picture. When an automated client signs its HTTP requests with a private key and publishes the matching public key at a location tied to its operator, your server can check that the request really was produced by someone holding that key.
The standard behind this is HTTP Message Signatures, published as RFC 9421. The IETF Web Bot Auth working group builds on it with conventions for automated clients: how an agent points to its key directory and which request components it should sign. As of November 2026, the working group's protocol document is draft-ietf-webbotauth-httpsig-protocol, which defines the Signature-Agent header, a key directory format based on JSON Web Key Sets and a well-known URI for serving it. Both are worth understanding in detail, because verification is easy to get subtly wrong.
Before the mechanics, be precise about the result. A valid signature shows that the request was signed by a key associated with a published identifier, such as an operator's domain, and that the signed parts of the request were not altered. It does not show who instructed the agent, whether a person delegated authority to it, or whether the action is safe. Identity evidence and authorization are separate questions, and your application answers the second one. Our blog post on what signed AI agent requests prove walks through that boundary with examples.
The headers involved
A signed request typically carries three headers:
Signature-Input(RFC 9421, Section 4.1) describes each signature: a label, the list of covered components, and parameters such ascreated,expires,keyid,alg,nonceandtag.Signature(RFC 9421, Section 4.2) carries the signature bytes for each label, base64 encoded, as a structured field.Signature-Agent(from the Web Bot Auth conventions) points to where the client's public keys are published, usually an HTTPS URL on the operator's domain.
A simplified example:
GET /api/catalog?page=2 HTTP/1.1
Host: shop.example
Signature-Agent: "https://agent.example"
Signature-Input: sig1=("@method" "@authority" "@path" "signature-agent");created=1762934400;expires=1762934700;keyid="kq1";alg="ed25519";tag="web-bot-auth";nonce="d9f1c2"
Signature: sig1=:Base64SignatureBytes==:The example is simplified for readability. In the current working group draft, Signature-Agent is itself a structured field keyed by signature label (for example sig1="https://agent.example"), so check which form your verification library expects.
Step 1: Parse the structured fields correctly
Signature-Input and Signature are HTTP Structured Fields (RFC 9651, which obsoletes RFC 8941). Do not parse them with string splitting. Quoted strings, parameters and inner lists all have precise grammar, and a sloppy parser creates both false rejections and real security gaps.
Use a maintained structured-field parser, or better, a maintained RFC 9421 library that handles parsing and verification together. Implementing the specification from scratch is a good learning exercise and a poor production choice. The steps below describe what such a library does, so you can configure it correctly and review its output.
Step 2: Choose which signature to evaluate
A request may carry more than one signature label. Select the one that matches your policy, for example the label whose tag is web-bot-auth. Reject the request for verification purposes (not for serving purposes) if:
- no signature has the parameters you require,
- the algorithm is not on your allowlist (for example
ed25519), or - the covered components do not include the minimum set you require.
A sensible minimum for automated clients is @method, @authority and @path, plus signature-agent when that header is present. Without @authority, a signature captured for one site could be replayed against another. Without @path, it could be reused across endpoints. The Web Bot Auth protocol draft requires agents to cover @authority or @target-uri, and warns that a signature covering only the authority verifies against any method or path on that host until it expires.
Step 3: Check time parameters
Read created and expires, the time parameters defined in RFC 9421, Section 2.3. Then:
- Reject signatures whose
createdtime is in the future beyond a small clock skew allowance. - Reject signatures that have passed
expires. - If
expiresis missing, enforce your own maximum age based oncreated.
Short validity windows limit how long a captured request can be replayed.
Step 4: Discover the key
The Signature-Agent header points to the operator's key directory. Fetch it over HTTPS, and only HTTPS. The Web Bot Auth protocol draft describes the directory as a JSON Web Key Set (keys in JWK format) served from the http-message-signatures-directory well-known URI on that origin. Then find the key whose identifier matches the keyid parameter.
type Jwk = { kid: string; kty: string; crv?: string; x?: string };
const keyCache = new Map<string, { keys: Jwk[]; fetchedAt: number }>();
const KEY_TTL_MS = 60 * 60 * 1000;
export async function getKey(directoryUrl: string, keyId: string): Promise<Jwk | null> {
const url = new URL(directoryUrl);
if (url.protocol !== "https:") return null;
const cached = keyCache.get(url.origin);
const fresh = cached && Date.now() - cached.fetchedAt < KEY_TTL_MS;
const keys = fresh ? cached.keys : await fetchKeys(url.origin);
return keys.find((k) => k.kid === keyId) ?? null;
}
async function fetchKeys(origin: string): Promise<Jwk[]> {
const res = await fetch(`${origin}/.well-known/http-message-signatures-directory`, {
headers: { accept: "application/http-message-signatures-directory+json, application/json" },
signal: AbortSignal.timeout(2000),
});
if (!res.ok) return [];
const body = (await res.json()) as { keys?: Jwk[] };
const keys = Array.isArray(body.keys) ? body.keys : [];
keyCache.set(origin, { keys, fetchedAt: Date.now() });
return keys;
}Practical rules for key discovery:
- Cache keys with a bounded lifetime so verification does not add a network call to every request.
- Set a short timeout and treat a failed fetch as "could not verify", never as "verified".
- Restrict which origins you fetch from if you can, or at least block private address ranges to avoid server-side request forgery. The protocol draft lists server-side request forgery in its security considerations for exactly this reason.
- Bind identity to the origin, not to the free-text name in the user agent. The identifier you record is the directory origin.
Step 5: Rebuild the signature base
The signer did not sign the raw request. It signed a canonical string called the signature base (RFC 9421, Section 2.5), built from the covered components in order, followed by the @signature-params line. Your verifier rebuilds the same string from the request it received.
For the example above the base looks like this:
"@method": GET
"@authority": shop.example
"@path": /api/catalog
"signature-agent": "https://agent.example"
"@signature-params": ("@method" "@authority" "@path" "signature-agent");created=1762934400;expires=1762934700;keyid="kq1";alg="ed25519";tag="web-bot-auth";nonce="d9f1c2"Watch for the details that break verification behind proxies:
@authoritymust be the host the client targeted. If a load balancer rewritesHost, use the original value it forwards.@pathexcludes the query string. If the client covered@query, rebuild it exactly as received.- Header values are canonicalized as specified: trimmed, with multiple values combined. A good library does this for you.
Step 6: Verify the signature
With the base and the public key, verification is a single call, following the steps in RFC 9421, Section 3.2. For Ed25519 in Node.js, using the built-in crypto module:
import { createPublicKey, verify } from "node:crypto";
export function verifyEd25519(jwk: Jwk, base: string, signatureB64: string): boolean {
const key = createPublicKey({ key: jwk as any, format: "jwk" });
return verify(null, Buffer.from(base, "utf8"), key, Buffer.from(signatureB64, "base64"));
}If the check fails, the request is not verified. It may still be served. It just does not get the "verified" basis.
Step 7: Prevent replay
A valid signature can be captured and resent until it expires. RFC 9421, Section 7.2.2 covers signature replay, and the Web Bot Auth protocol draft defers to it rather than adding its own nonce rules. Two controls close most of that window:
- Short expiry, enforced in Step 3.
- Nonce tracking. When a
nonceis present, store it with its key identifier until the signature expires, and reject a second use.
const seen = new Map<string, number>();
export function acceptNonce(keyId: string, nonce: string, expiresAt: number): boolean {
const id = `${keyId}:${nonce}`;
const now = Date.now() / 1000;
for (const [k, exp] of seen) if (exp < now) seen.delete(k);
if (seen.has(id)) return false;
seen.set(id, expiresAt);
return true;
}In a multi-instance deployment, keep nonces in a shared store such as Redis with a time to live equal to the remaining validity.
Step 8: Record the result with its basis
Verification produces evidence, not a verdict. Record it in a form your analytics and policies can use:
const result = {
actor: "agent",
basis: "verified",
operator: "https://agent.example",
keyId: "kq1",
coveredComponents: ["@method", "@authority", "@path", "signature-agent"],
verifiedAt: new Date().toISOString(),
};When verification fails or cannot run, record that too, with the reason: missing headers, unsupported algorithm, key not found, expired, replayed, or signature mismatch. These reasons are valuable. A spike in "key not found" from one operator often means they rotated keys, not that they are attacking you.
What should you log from signature verification?
Log the operator origin, key identifier, covered components, time parameters, the outcome and the failure reason. Do not log the full signature bytes or entire request bodies. Keep the logs long enough to study patterns and short enough to respect your privacy commitments.
How should a verified signature change your policy?
Once requests can be verified, you can treat them differently in a meaningful way. Our post on designing app policies for humans and AI agents covers the wider policy model:
- Allow verified operators onto read-only routes with a higher rate limit than unverified automation.
- Offer them structured responses, such as JSON or Markdown, instead of full HTML pages.
- Keep high-impact actions behind your normal authorization. A verified signature tells you which client is calling, not that it may change an account.
Start in observation mode. Measure how much of your automated traffic is signed, which operators appear, and how often verification fails, before any policy depends on it.
Where Proof of Actor fits
Signature verification is one source of evidence among several. Proof of Actor combines it with other signals and keeps the basis visible on every result, so a verified agent is never confused with one that only declared itself. Your application decides what happens next.
To build this into a production app with our engineers, apply for early access. For the full picture of what the evidence can and cannot show, read Detection & Limitations.
Sources
- RFC 9421: HTTP Message Signatures: the signing and verification standard, including signature parameters, the signature base and replay considerations
- RFC 9651: Structured Field Values for HTTP: the grammar used by
Signature-InputandSignature - IETF Web Bot Auth working group: charter and status of the automated client authentication work
- draft-ietf-webbotauth-httpsig-protocol: Internet-Draft defining
Signature-Agent, the JWKS key directory and its well-known URI (work in progress as of November 2026) - RFC 7517: JSON Web Key (JWK): the key format used in published key directories
- RFC 8615: Well-Known URIs: how well-known paths such as the key directory are registered
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA): the Ed25519 algorithm used in the example
- Node.js crypto documentation:
createPublicKeyandverifyused in Step 6