Signed Agent Cards: how JWS and RFC 8785 prove who published a card
What A2A signs in an Agent Card, how RFC 8785 and JWS fit together, how to verify a signature step by step, and what a valid one proves.
A signed Agent Card carries one or more JSON Web Signatures (JWS, RFC 7515) in its signatures array. To produce one, the publisher removes the signatures field and any default-valued fields from the card, turns what remains into one exact byte string with the JSON Canonicalization Scheme (RFC 8785), and signs those bytes with a private key. A client holding the matching public key rebuilds the same bytes from the card it received and checks the signature.
A valid signature proves two things: the signed content has not changed, and it was produced by whoever controls the signing key. On its own, it says nothing about who controls that key or whether they deserve trust.
Why sign an Agent Card
An Agent Card describes an A2A server: name, provider, endpoints in supportedInterfaces, skills and security requirements. Clients usually fetch it from https://{domain}/.well-known/agent-card.json over HTTPS, which proves the bytes came from that host. The specification also lists registries, catalogs and direct configuration as ways to find cards (section 8.2), and cards get cached and passed between systems. Once a card leaves its origin, TLS says nothing about it.
A signature travels with the card. It gives you:
- Integrity. Change any signed field, such as an endpoint URL or a security scheme, and verification fails.
- Publisher binding. The card is tied to a key. If that key is published at an HTTPS location you already associate with the provider, the card is tied to whoever runs that location.
Signing is optional in A2A. The specification says clients SHOULD verify at least one signature before trusting a card, and a card MAY carry several signatures, for example during key rotation.
What the A2A specification says gets signed
Section 8.4 of the A2A v1.0 specification defines the process. The payload is the Agent Card with two kinds of content removed, then canonicalized with RFC 8785.
The signatures field is always excluded. A signature cannot cover itself.
Default-valued fields are removed according to Protocol Buffers field presence. A2A defines its data model in a2a.proto, and the JSON form uses camelCase names for the same fields. The rules in section 8.4.1, which build on section 5.7, are:
Field in a2a.proto |
Rule before canonicalizing |
|---|---|
Marked REQUIRED |
Always included, even at its default value, such as "description": "" or "skills": [] |
Declared optional and explicitly set |
Included, even if the value equals the default, such as "streaming": false |
Declared optional and not set |
Omitted |
Any other field at its default value (empty list, empty map, empty string, false, 0) |
Omitted |
In the current proto, the AgentCard fields marked REQUIRED are name, description, supportedInterfaces, version, capabilities, defaultInputModes, defaultOutputModes and skills. documentationUrl, iconUrl and the three capability flags (streaming, pushNotifications, extendedAgentCard) are declared optional. Repeated and map fields such as securitySchemes, securityRequirements and capabilities.extensions drop out when empty.
Illustrative example. A server emits this card fragment:
{
"name": "Example Freight Quotes",
"description": "Returns freight quotes for pallet shipments.",
"capabilities": { "streaming": false, "extensions": [] },
"securitySchemes": {},
"skills": [
{
"id": "quote",
"name": "Freight quote",
"description": "Quote a pallet shipment.",
"tags": ["freight"],
"examples": []
}
]
}
"streaming": false stays because it is an optional field that was set. The empty extensions, securitySchemes and examples go. After RFC 8785, the payload is:
{"capabilities":{"streaming":false},"description":"Returns freight quotes for pallet shipments.","name":"Example Freight Quotes","skills":[{"description":"Quote a pallet shipment.","id":"quote","name":"Freight quote","tags":["freight"]}]}
A complete card also needs supportedInterfaces, version and the two default mode lists.
The AgentCardSignature object
Each entry in signatures is an AgentCardSignature with three fields:
| Field | Required | Content |
|---|---|---|
protected |
Yes | The JWS Protected Header: a JSON object, base64url-encoded |
signature |
Yes | The signature bytes, base64url-encoded |
header |
No | The JWS Unprotected Header, as a plain JSON object |
The protected header must contain alg (the algorithm, such as ES256) and kid (the key ID). The specification says typ should be "JOSE". jku, a URL for a JWK Set holding the public key, is optional.
JWS in brief
RFC 7515 defines what gets signed: the JWS Signing Input. It is the base64url-encoded protected header, a period, and the base64url-encoded payload. For a card, the payload is the canonical JSON from the previous step as UTF-8 bytes. Base64url means the URL-safe alphabet with trailing = padding removed.
The card’s signatures array mirrors the general JWS JSON serialization, which pairs a top-level payload with a signatures array of protected, header and signature entries. A card has no payload member: the verifier rebuilds the payload from the card itself. RFC 7515 Appendix F calls this detached content. Canonicalization is what makes the rebuild reliable.
Two details matter for security:
- Only the protected header is signed. Anyone who handles the card can change the unprotected
headerobject. Readalg,kidandjkufrom the protected header only. - ES256 signatures are 64 bytes. RFC 7518 section 3.4 defines the ES256 signature as the 32-byte R value followed by the 32-byte S value, written
R||S. A library that emits ECDSA signatures in ASN.1 DER encoding needs a conversion step. WebCrypto produces the raw 64-byte form directly.
RFC 8785: one byte string per JSON value
The same JSON data can be written many ways: keys in any order, optional whitespace, 4.50 or 4.5, "\/" or "/". A signature covers bytes, so signer and verifier must agree on exactly one form. The JSON Canonicalization Scheme (JCS, RFC 8785) defines it. RFC 8785 is an Informational RFC; A2A makes it mandatory for card signing.
| Aspect | JCS rule |
|---|---|
| Whitespace | None between tokens |
| Object keys | Sorted recursively by UTF-16 code units, compared as unsigned integers, independent of locale |
| Arrays | Element order kept; objects inside arrays still get their keys sorted |
| Strings | " and \ are escaped. Control characters U+0000 to U+001F become \b, \t, \n, \f, \r or a lowercase escape such as \u000f. Everything else, including / and non-ASCII text, is written as-is |
| Numbers | IEEE 754 doubles, written the way ECMAScript writes them: 4.50 becomes 4.5, 1E30 becomes 1e+30 |
| Literals | null, true, false |
| Output encoding | UTF-8 |
Input must follow I-JSON (RFC 7493): no duplicate keys, and numbers that fit an IEEE 754 double (RFC 8785 recommends sending larger integers as strings). Lone surrogates, NaN and Infinity are errors. JCS applies no Unicode normalization. Because RFC 8785 took its string and number rules from ECMAScript, JavaScript’s JSON.stringify already gets those right; only recursive key sorting is missing.
Key discovery: kid, jku and JWKS
The protected header tells the verifier which key to use.
kidis a case-sensitive string with no required structure. It selects a key from a set. RFC 7517 says keys in one JWK Set should have distinctkidvalues.jkuis a URL that points to a JWK Set. RFC 7515 requires fetching it over TLS with the server identity validated.- A JWK Set (RFC 7517 section 5) is a JSON object with a
keysarray. Each entry is a public key in JWK form. For ES256 that means"kty": "EC","crv": "P-256"and thexandycoordinates.
The A2A specification allows either route: fetch the key using kid and jku, or look it up in a trusted key store the client maintains. It also says keys should be retrieved over HTTPS and that expired or revoked keys must not be used.
Note that the signer chooses jku. A verifier that fetches whatever URL the card names can be fooled: an attacker edits the card, points jku at a JWK Set they control, and re-signs it with their own key. So decide which key locations you accept, such as the card endpoint’s host, the provider’s domain or an allowlist. The A2A specification leaves that binding to you.
How to verify a signed card
This procedure combines the A2A specification (section 8.4.3), RFC 7515 (section 5.2) and the algorithm guidance in RFC 8725.
- Parse the card. Reject duplicate keys. Keep the JSON as received; don’t round-trip it through a typed model that may add or drop fields.
- Decode each protected header. Base64url-decode
protectedand parse it as JSON. - Check the algorithm. Accept only algorithms on your allowlist, such as
ES256. Never acceptnone, and use each key with exactly one algorithm. If the header has acritlist naming parameters you don’t support, the signature is invalid. - Resolve the key. Use
kidwith ajkuthat passes your location policy, or use your own key store. Refuse expired or revoked keys. - Rebuild the payload. Copy the card, delete
signatures, remove default-valued fields as section 8.4.1 describes, canonicalize with RFC 8785 and encode as UTF-8. - Verify. Build the signing input (
protected, a period, then the base64url-encoded payload) and check the signature with the public key. - Decide. If at least one signature verifies under your policy, the card is intact and was signed by that key. Trusting the key’s owner is a separate decision, covered in the last section.
A runnable example
The script below uses only Node.js and its built-in WebCrypto API (Node 20 or later). It generates an ES256 key pair, canonicalizes an illustrative card with RFC 8785, signs it, prints the public key as a JWK Set, and verifies the signature. It then checks two altered copies: one with reordered keys and different whitespace, and one with a changed endpoint URL.
// sign-card.mjs: sign and verify an A2A Agent Card with ES256 and RFC 8785. Node 20+, no dependencies.
const { subtle } = globalThis.crypto;
const utf8 = (s) => new TextEncoder().encode(s);
const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
// RFC 8785 (JCS). JSON.stringify already writes strings and numbers the way JCS requires.
// It does not sort keys, so sort them recursively (default JS sort = UTF-16 code units).
function jcs(v) {
if (typeof v === "number" && !Number.isFinite(v)) throw new Error("JCS: NaN/Infinity");
if (typeof v === "string" && !v.isWellFormed()) throw new Error("JCS: lone surrogate");
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return `[${v.map(jcs).join(",")}]`;
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
return `{${keys.map((k) => `${jcs(k)}:${jcs(v[k])}`).join(",")}}`;
}
// A2A 8.4: payload = the card minus `signatures` (and minus default-valued fields), canonicalized.
const payloadOf = ({ signatures, ...rest }) => jcs(rest);
async function sign(card, privateKey, kid, jku) {
const protectedHeader = b64u(utf8(JSON.stringify({ alg: "ES256", typ: "JOSE", kid, jku })));
const input = `${protectedHeader}.${b64u(utf8(payloadOf(card)))}`;
const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, utf8(input));
return { protected: protectedHeader, signature: b64u(sig) }; // WebCrypto emits raw R||S, as JWS requires
}
async function verify(card, jwks) {
for (const s of card.signatures ?? []) {
const header = JSON.parse(Buffer.from(s.protected, "base64url").toString("utf8"));
if (header.alg !== "ES256") continue; // pin the algorithm you accept
const jwk = jwks.keys.find((k) => k.kid === header.kid);
if (!jwk) continue;
const key = await subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
const input = utf8(`${s.protected}.${b64u(utf8(payloadOf(card)))}`);
const ok = await subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, Buffer.from(s.signature, "base64url"), input);
if (ok) return header.kid;
}
return null;
}
// Illustrative card for a fictional agent.
const card = {
name: "Example Freight Quotes",
description: "Returns freight quotes for pallet shipments.",
supportedInterfaces: [{ url: "https://agent.example.com/a2a/v1", protocolBinding: "JSONRPC", protocolVersion: "1.0" }],
provider: { organization: "Example Freight", url: "https://example.com" },
version: "1.0.0",
capabilities: { streaming: false },
defaultInputModes: ["application/json"],
defaultOutputModes: ["application/json"],
skills: [{ id: "quote", name: "Freight quote", description: "Quote a pallet shipment.", tags: ["freight"] }],
};
const kid = "card-key-2026-09";
const { privateKey, publicKey } = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const { kty, crv, x, y } = await subtle.exportKey("jwk", publicKey);
const jwks = { keys: [{ kty, crv, x, y, kid, alg: "ES256", use: "sig" }] }; // publish at the jku URL
console.log("Canonical payload:\n" + payloadOf(card));
card.signatures = [await sign(card, privateKey, kid, "https://agent.example.com/.well-known/jwks.json")];
console.log("\nAgentCardSignature:", JSON.stringify(card.signatures[0], null, 2));
console.log("\nJWKS to publish at the jku URL:", JSON.stringify(jwks));
console.log("\nVerified with kid:", await verify(card, jwks));
// Same content, different key order and whitespace: still verifies.
const reordered = JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(card).reverse()), null, 4));
console.log("Reordered copy verifies:", (await verify(reordered, jwks)) !== null);
// One changed field: fails.
const tampered = structuredClone(card);
tampered.supportedInterfaces[0].url = "https://attacker.example/a2a/v1";
console.log("Tampered copy verifies:", (await verify(tampered, jwks)) !== null);
Save it as sign-card.mjs and run node sign-card.mjs. Output from one run:
Canonical payload:
{"capabilities":{"streaming":false},"defaultInputModes":["application/json"],"defaultOutputModes":["application/json"],"description":"Returns freight quotes for pallet shipments.","name":"Example Freight Quotes","provider":{"organization":"Example Freight","url":"https://example.com"},"skills":[{"description":"Quote a pallet shipment.","id":"quote","name":"Freight quote","tags":["freight"]}],"supportedInterfaces":[{"protocolBinding":"JSONRPC","protocolVersion":"1.0","url":"https://agent.example.com/a2a/v1"}],"version":"1.0.0"}
AgentCardSignature: {
"protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJjYXJkLWtleS0yMDI2LTA5Iiwiamt1IjoiaHR0cHM6Ly9hZ2VudC5leGFtcGxlLmNvbS8ud2VsbC1rbm93bi9qd2tzLmpzb24ifQ",
"signature": "jKEbnh6b1iowkpKmZZLAzbSZfO76WFpo2pF_EpU_jnwtzveAhqIAd1epITeulrsphO536oZwxUg5QLUtyXD_Hw"
}
JWKS to publish at the jku URL: {"keys":[{"kty":"EC","crv":"P-256","x":"dvCLp_O5ee2K-Y5li-eKGaSRGmz_brzCGCQ3W-HTPsk","y":"BaVZYlbJRrqbEGRczDPX6l4IBoVYjH4-Fwio68D-5kA","kid":"card-key-2026-09","alg":"ES256","use":"sig"}]}
Verified with kid: card-key-2026-09
Reordered copy verifies: true
Tampered copy verifies: false
The key and the signature change on every run: the key pair is new each time, and ECDSA signatures include a random value. The payload, the protected header and the three results do not. The jcs function reproduces the RFC 8785 test vectors in sections 3.2.2 to 3.2.4, byte for byte. payloadOf removes only signatures, which is enough for this sample card. A production verifier needs the proto schema to know which fields are REQUIRED or optional before stripping defaults.
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| A card that looks unchanged fails to verify | A server, SDK or cache re-serialized it and added default-valued fields, such as "extensions": [] or an extension’s "required": false |
Apply the section 8.4.1 rules on both sides before canonicalizing |
Explicit false capability flags vanish and verification fails |
The card passed through a typed model without field presence, which dropped "streaming": false |
Verify the JSON as received, or use a parser that tracks presence |
| Only cards with unusual characters in keys fail | Keys sorted by UTF-8 bytes or locale rules instead of UTF-16 code units | Sort by UTF-16 code units |
Cards with numbers fail, for example in extension params |
A serializer that writes 1.0, 1E30 or extra digits |
Use ECMAScript number formatting |
| Every ES256 signature from one signer fails | The signature is DER-encoded instead of the raw 64-byte form | Convert DER signatures to the raw form before encoding |
| Verification fails after the signer rotates keys | The verifier cached an old JWK Set | Refetch the JWK Set when a kid is unknown, with a rate limit |
| A forged card passes verification | jku pointed at a key set the attacker controls |
Enforce a key-location policy or a trusted key store |
What a signature does not prove
A valid signature establishes that the card is unchanged since signing and that the signer held the private key. Everything else is outside its reach:
- Who holds the key. Anyone can generate a key pair and sign a card that names any organization in
provider. The signature binds the card to a key; nothing binds the key to a company unless you add that link. - Whether the provider is trustworthy. A legitimate key can belong to a careless or malicious operator.
- What the agent does. The card describes skills. A signature doesn’t show that the agent behind the endpoint performs them as described.
- Who sent a given request. A card signature covers the card. Authenticating the requests an agent sends is a separate mechanism, described by the card’s
securitySchemesand section 7 of the specification.
Publishing the key under a domain the provider controls, through jku over HTTPS, narrows the first gap: the card was signed by whoever controls that key location. It still doesn’t tell a business whether to act on an incoming agent’s request. That needs checks on the provider’s identity, a record of how its agents behave, and a policy that turns both into a decision. This is the know-your-agent problem.
Emissar’s Verify module is aimed at this layer: validate the card signature and signing key, match the key to a known provider record, assess the request, and return allow, ask for more proof, or refuse. Its status is In development.
Questions
- Does A2A require Agent Cards to be signed?
- No. Signing is optional: the specification says cards MAY be signed. It also says clients SHOULD verify at least one signature before trusting a card, so publishers that want their cards trusted by careful clients should sign them.
- Which signing algorithm should I use?
- The specification requires an alg value in the protected header and gives ES256 and RS256 as examples. It does not restrict the choice. Pick an algorithm your verifiers support, publish the public key as a JWK Set, and have verifiers pin the algorithms they accept.
- How do I rotate a card signing key?
- Add the new public key to your JWK Set under a new kid, then add a second entry to the card's signatures array signed with the new key. The specification allows multiple signatures for this purpose. Once clients have had time to pick up the new key, remove the old signature and retire the old key. Verifiers must not accept expired or revoked keys.
- Why canonicalize instead of signing the card file's exact bytes?
- Servers, SDKs, caches and registries parse and re-serialize JSON, which changes key order, whitespace and number formatting. RFC 8785 lets any party rebuild the same bytes from the parsed card, so the signature survives those round trips.
Sources
- A2A Protocol Specification (sections 5.7 and 8.4) (accessed )
- A2A protocol buffer definition (a2a.proto) (accessed )
- RFC 7515: JSON Web Signature (JWS) (accessed )
- RFC 7517: JSON Web Key (JWK) (accessed )
- RFC 7518: JSON Web Algorithms (JWA) (accessed )
- RFC 8785: JSON Canonicalization Scheme (JCS) (accessed )
- RFC 8725: JSON Web Token Best Current Practices (accessed )