Signed Agent Cards: preventing poisoning with JWS
When AI agents discover each other across public networks by fetching an agent-card.json file,
trust cannot depend on trusting the network. An unsigned card is a single DNS spoof or CDN misconfiguration away
from redirecting autonomous systems to malicious endpoints. In A2A v1.0, signed Agent Cards solve this with JSON Web Signatures.
Why unsigned cards are vulnerable
In the A2A protocol specification, discovery is designed to be lightweight, static, and unauthenticated: a client agent performs a simple HTTP GET request to /.well-known/agent-card.json.
While this simplicity enabled rapid ecosystem adoption, it exposes an attack surface that you can call Agent Card poisoning:
- Endpoint Redirection: An attacker who compromises a DNS entry or an edge proxy can rewrite the
supportedInterfaces[].urlfield, silently sending sensitive prompt payloads to an eavesdropping or impersonation server. - Capability & Prompt Injection: By tampering with the
skillsarray ordescriptionfields, malicious actors can deceive calling agents into delegating high-privilege financial or administrative tasks to an unvetted model. - Identity Spoofing: Without cryptographic verification, anyone can host an agent card claiming that
provider.organizationis an established financial institution or software vendor.
To defend multi-agent architectures against these risks, the specification maintained in the a2aproject/A2A repository lets a card carry a signatures array of RFC 7515: JSON Web Signature (JWS) objects (spec section 8.4).
Anatomy of the signatures array
In an A2A v1.0 Agent Card, the top-level signatures property holds an array of AgentCardSignature objects. Several entries are allowed, which is how key rotation works: during a rotation a card carries one signature from the old key and one from the new.
{
"name": "Treasury Reconciliation Agent",
"version": "1.2.0",
"supportedInterfaces": [
{
"url": "https://agents.acme-finance.com/a2a/v1",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"signatures": [
{
"protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vYWNtZS1maW5hbmNlLmNvbS8ud2VsbC1rbm93bi9qd2tzLmpzb24ifQ",
"signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ"
}
]
}
Each entry has the fields of a flattened JWS, and nothing else is required:
-
protected(required): A Base64URL-encoded JSON object holding the JWS protected header. It must contain:alg: The signing algorithm, such asES256orRS256.kid: The key ID, which identifies the signing key.typ: Should be set toJOSE.
It may also contain
jku, the URL of the JSON Web Key Set that holds the public key. The example above decodes to{"alg":"ES256","typ":"JOSE","kid":"key-1","jku":"https://acme-finance.com/.well-known/jwks.json"}. -
signature(required): The Base64URL-encoded signature value. -
header(optional): A JSON object of unprotected header parameters, not Base64URL-encoded.
Signing an agent-card.json with ES256
What is signed is not the file as served but its canonical form. Per section 8.4.1 of the specification, you:
- Remove the
signaturesfield itself, to avoid a circular dependency. - Respect Protocol Buffer field presence: drop fields that hold a default value (an empty array, an empty string) unless they are required or explicitly set optional fields.
- Canonicalize the result with the JSON Canonicalization Scheme (RFC 8785): object keys sorted, no insignificant whitespace.
The JWS signing input is then BASE64URL(protected header) + "." + BASE64URL(canonical card). Here is a Node.js version that follows those steps with no dependencies. Sorting keys and JSON.stringify is enough to produce the RFC 8785 form for ordinary cards; reach for a vetted JCS library if yours carries exotic numbers.
import crypto from 'node:crypto';
import fs from 'node:fs';
// RFC 8785 (JCS): keys sorted by UTF-16 code units, no whitespace. JSON.stringify
// already serializes strings and numbers the way JCS requires.
const canonicalize = value =>
Array.isArray(value)
? `[${value.map(canonicalize).join(',')}]`
: value !== null && typeof value === 'object'
? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`
: JSON.stringify(value);
// 1. Read the card and drop any existing signatures.
const card = JSON.parse(fs.readFileSync('agent-card.json', 'utf8'));
const { signatures, ...unsigned } = card;
// 2. Build the JWS protected header: alg, typ and kid are required.
const header = {
alg: 'ES256',
typ: 'JOSE',
kid: 'key-1',
jku: 'https://acme-finance.com/.well-known/jwks.json'
};
const protectedB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
const payloadB64 = Buffer.from(canonicalize(unsigned)).toString('base64url');
// 3. Sign with your P-256 private key. A JWS ES256 signature is the raw R||S
// pair (RFC 7518 section 3.4), not Node's default DER encoding.
const signature = crypto.sign('sha256', Buffer.from(`${protectedB64}.${payloadB64}`), {
key: fs.readFileSync('private-key.pem'),
dsaEncoding: 'ieee-p1363'
}).toString('base64url');
// 4. Attach the signature to the card.
const signedCard = { ...unsigned, signatures: [{ protected: protectedB64, signature }] };
fs.writeFileSync('agent-card.signed.json', JSON.stringify(signedCard, null, 2));
console.log('Agent Card signed with ES256');
How client agents verify signatures
When a client agent retrieves an Agent Card from an unvetted peer, verification should happen before it inspects capabilities or invokes interfaces. Section 8.4.3 of the specification gives the steps:
- Extraction: Take a signature from the
signaturesarray. If there is none, treat the card as unverified, or reject it, according to your security policy. - Key retrieval: Fetch the public key named by
kid, from the JWKS atjkuor from a trusted key store of known providers. - Rebuild the payload: Remove default-valued properties and the
signaturesfield from the received card, then canonicalize it with RFC 8785. - Verify: Check the signature over
BASE64URL(protected) + "." + BASE64URL(canonical card)with the public key.
Beyond the mechanics, the specification is explicit about trust: clients SHOULD verify at least one signature before trusting a card, public keys SHOULD be fetched over HTTPS, and expired or revoked keys MUST NOT be used. Multiple signatures are allowed so that rotation does not break verification.
Two policies are worth adding on top, even though the specification does not require them: reject algorithms below your security baseline (always none, and symmetric ones), and check that the key's origin matches the provider.url the card declares, so that a signature from one organization cannot vouch for another's name.
Key rotation and security checklist
Follow these operational guidelines when managing signed Agent Cards in production:
- Decouple Key Lifecycle from Deployments: Name keys with
kidand publish them in a JWKS (/.well-known/jwks.json) thatjkupoints at, rather than hardcoding static keys. To rotate, add the new key to the JWKS, sign with both keys for a while, then retire the old one. - Re-sign on every change: The signature covers the whole card. Make signing a build step, so the version bump and the new signature ship together.
- Enforce HTTPS Everywhere: Every interface listed under
supportedInterfacesand every key endpoint must be served over HTTPS with a valid certificate. - Bind Domain to Provider: Keep the domain that hosts the Agent Card, the
provider.url, and the origin of the JWKS aligned. - Pre-flight Audit: Run your completed card through the validator before publishing it to your public root, and replace any scaffold signature the generator emitted.
Frequently asked questions
Are JWS signatures mandatory for an Agent Card in A2A v1.0?
No. The specification says Agent Cards MAY be signed, and that clients SHOULD verify at least one signature before trusting a card that has them. Signatures are still worth adding to any agent exposed across public or multi-tenant networks, because without them a client cannot tell whether the card was altered on its way to it.
What is "Agent Card poisoning"?
It is tampering with an agent's card so that callers trust the wrong thing: an attacker who controls a DNS entry, a CDN configuration or a compromised storage bucket rewrites the card to redirect interface endpoints to malicious infrastructure or to inject fabricated skill descriptions.
Which signing algorithm should I use?
The specification's examples use ES256 and RS256. ES256 (ECDSA on P-256 with SHA-256) is a good default: signatures are small and library support is wide. Never use the "none" algorithm, and avoid symmetric HS256-style algorithms, because clients verify an Agent Card with a public key.
Where should public keys be hosted?
In a JSON Web Key Set served over HTTPS under the provider's own domain, commonly at /.well-known/jwks.json. The protected header names the key with kid and can point at the set with jku; clients may instead use a trusted key store of known providers.
How does the Agent Card validator evaluate signatures?
It checks the format only. Each signatures entry needs a base64url protected header that declares alg and kid (typ should be "JOSE") and a base64url signature that is not a placeholder. It does not verify the signature itself, because that needs the signer's public key and the canonical form of the card; use your SDK or a JWS library for that.
References
Related tool: Agent Card Validator
Read more guides
Next step
Test your Agent Card integrity before deployment.
The validator inspects your card structure, checks JWS signature headers for alg, kid and typ, and flags placeholder signatures and unencrypted interface endpoints.