Agent Card Reader / Agent card signatures
How A2A agent card signatures work
A card's signatures array holds JSON Web Signatures (RFC 7515). The payload is detached: it is the card itself, without the signatures field, with default values removed, canonicalized with the JSON Canonicalization Scheme (RFC 8785). Spec §8.4.
What gets signed
- Remove properties with default values (§8.4.1). Fields explicitly set stay, even
false; empty repeated fields that are not required, such as"extensions": [], go. - Exclude the
signaturesfield. - Canonicalize with RFC 8785: keys sorted, no insignificant whitespace, numbers and strings in their canonical form.
The signing input is ASCII(protected) || "." || BASE64URL(payload), exactly as for a compact JWS.
The signature object
| Field | Required | Contents |
|---|---|---|
protected | Yes | Base64url-encoded JSON header. Must include alg (for example ES256, RS256, EdDSA) and kid; typ should be JOSE; jku may point to a JWKS. |
signature | Yes | Base64url-encoded signature bytes. |
header | No | Unprotected header, as a JSON object. |
Verifying a signature
Per §8.4.3: fetch the public key by kid from jku (or a trusted key store), rebuild the canonical payload, and verify. In a browser, WebCrypto does it without libraries:
const b64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')
+ '='.repeat((4 - s.length % 4) % 4)), (c) => c.charCodeAt(0));
const enc = (s) => btoa(String.fromCharCode(...new TextEncoder().encode(s)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// RFC 8785 for JSON without exotic numbers: sorted keys, no whitespace.
const jcs = (v) => v === null || typeof v !== 'object' ? JSON.stringify(v)
: Array.isArray(v) ? `[${v.map(jcs)}]`
: `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + jcs(v[k]))}}`;
async function verifyCard(card, sig, jwk) { // jwk: the key from jku, matched by kid
const { signatures, ...unsigned } = card; // default removal omitted for brevity
const input = `${sig.protected}.${enc(jcs(unsigned))}`;
const key = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['verify']);
return crypto.subtle.verify({ name: 'Ed25519' }, key, b64u(sig.signature), new TextEncoder().encode(input));
}
For ES256 use { name: 'ECDSA', namedCurve: 'P-256' } to import and { name: 'ECDSA', hash: 'SHA-256' } to verify; for RS256, RSASSA-PKCS1-v1_5 with SHA-256.
Worked example: getbody.md
The live card at https://getbody.md/.well-known/agent-card.json carries one signature whose protected header decodes to:
{ "alg": "EdDSA", "jku": "https://getbody.md/.well-known/jwks.json", "kid": "getbody-a2a-1", "typ": "JOSE" }
The JWKS at that jku holds an Ed25519 key with that kid. The signature verifies over JCS of the card without default removal. The card's security field (the 0.3 name for requirements) contains an empty scopes list, "getbodyBearer": [], which default removal would drop. The reader therefore tries the spec's canonical form first, then plain JCS, and says which one matched. See it in the reader.
Common pitfalls
- Signing the pretty-printed file instead of the canonical form.
- Leaving
signaturesin the payload. - Skipping default removal on one side only: signer and verifier must agree.
- Serving the JWKS without CORS, so browser clients cannot fetch the key.
- A
kidthat does not match any key in the JWKS.
Based on the A2A specification (latest, 1.0). Updated 2026-10-14. Check a card now: open the reader.