The A2A Agent Card explained: discovery, supported interfaces, and signature verification
Before two agents can collaborate, the caller has to answer three questions without a private introduction: where the agent is, what it can do, and how to talk to it. In the Agent2Agent protocol the answer to all three i
Before two agents can collaborate, the caller has to answer three questions without a private introduction: where the agent is, what it can do, and how to talk to it. In the Agent2Agent protocol the answer to all three is one public document, the Agent Card, served at a well-known URL. The card is the most important piece of A2A to get right because everything downstream, transport selection included, is derived from it.
Where a card lives
An agent publishes its card at a fixed, unauthenticated path:
curl https://expense-agent.example.com/.well-known/agent-card.json
Discovery is deliberately public. A client should be able to learn an agent's skills and endpoints before it authenticates, the same way it can read an OpenAPI document before calling a protected operation. Because the card is fetched before trust is established, the fetch itself has to be conservative. In practice that means:
- Only
httpandhttpsURLs, and never credentials embedded in the URL (nohttps://user:pass@host/...). - No cookies and no redirect-following during discovery; a card that redirects elsewhere is a signal worth surfacing, not silently chasing.
- A hard response size ceiling (a 1 MiB cap is reasonable) so a malicious endpoint cannot stream an unbounded body into the client.
- An explicit
Accept: application/jsonand a short timeout.
Treat the card as untrusted input at the parser level, not just the network level.
The minimum that makes a card valid
A useful validator stays strict about the load-bearing fields and lenient about the rest. At minimum a card must have a string name and an array skills; anything that fails those two checks is not an agent card and should be rejected rather than half-rendered.
A realistic card looks like this:
{
"name": "Expense Agent",
"description": "Creates, reviews, and reimburses expense reports",
"version": "1.0.0",
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": [
{
"id": "submit-expense",
"name": "Submit expense",
"description": "File an expense report from receipts and a cost center",
"tags": ["finance", "expenses"]
},
{
"id": "approve-expense",
"name": "Approve expense",
"description": "Review a submitted report and approve or reject it",
"tags": ["finance", "approvals"]
}
],
"supportedInterfaces": [
{ "protocolVersion": "1.0", "protocolBinding": "JSONRPC", "url": "https://expense-agent.example.com/rpc" },
{ "protocolVersion": "1.0", "protocolBinding": "HTTP+JSON", "url": "https://expense-agent.example.com/rest" },
{ "protocolVersion": "1.0", "protocolBinding": "GRPC", "url": "https://expense-agent.example.com:50051/" }
]
}
The skills array is the capability resume. Each skill's id, name, and human-readable description is what a routing agent uses to decide whether this peer is the right destination. Vague skill descriptions are a real operational problem: when three agents all advertise "help with requests", delegation becomes a coin flip. Write skill descriptions the way you would write operation summaries in an OpenAPI document, for a reader who has no other context.
supportedInterfaces drives transport selection
The supportedInterfaces array tells the client how to reach the agent. Each entry pairs a protocol version with a binding and a URL. A2A 1.0 defines three bindings:
-
JSONRPC over HTTP, the most general option, with the standard
jsonrpc/id/method/paramsenvelope. - HTTP+JSON (REST), where the request body is the params object directly, with no JSON-RPC wrapper.
- GRPC, native gRPC using the official service descriptor rather than hand-encoded messages.
A well-built client reads this array and configures itself: pick the binding that matches the network path, fill in the endpoint and version, and generate the right request template. Switching from JSON-RPC to REST is not just a header change; the envelope disappears, so the body template must be regenerated. The earlier 0.3 line is JSON-RPC only, and it is not wire compatible with 1.0; method names, part shapes, and the role enum all differ. The card's protocolVersion is authoritative.
Why an unsigned card is not enough on its own
Discovery answers what the card says. It does not answer who published it. A card fetched over a compromised path, or from a host that has been silently re-pointed, can advertise an attacker-controlled endpoint with convincing skills. This is the classic agent-to-agent trust problem: the document that tells you where to send work is itself the thing an attacker most wants to rewrite.
The defense is a signed card plus a pinned set of trusted public keys. The card carries a signatures array; the client verifies at least one signature against a JWKS it already obtained through a trusted out-of-band channel, such as a service registry or configuration you control.
The verification rules that matter, in order:
-
Never follow key-URL hints in the card itself. Headers such as
jku,x5u, or an embeddedjwkare exactly the levers an attacker who can rewrite the card controls. The trusted keys come from your pinned JWKS, nowhere else. -
Only asymmetric public keys are accepted. A symmetric key (
kty: oct) or any JSON containing private fields (d,p,q, and the other CRT parameters) is rejected; a verifier must never hold a secret. -
Pin the algorithm. Allow an explicit allowlist such as
ES256,ES384,ES512,RS256,PS256, andEdDSA, and refuse detached payloads (b64: false). - Verify the canonicalized card, not the raw bytes. The signed payload is the canonical JSON form of the card, so reformatting, whitespace, or key ordering cannot invalidate a legitimate signature, and changing a single skill URL still breaks it.
-
Match by
kidand support rotation. A card may carry several signatures for rotating keys; the client walks them and accepts the first one that verifies against a trusted key id. One good signature is sufficient; none matching means the card was modified or came from an untrusted publisher.
A pinned JWKS has the familiar shape, with unique key ids and public keys only:
{
"keys": [
{
"kid": "expense-agent-2026-10",
"kty": "EC",
"crv": "P-256",
"alg": "ES256",
"use": "sig",
"x": "BASE64URL_X",
"y": "BASE64_URL_Y"
}
]
}
The operational payoff is that trust is anchored in something the card cannot modify. Distribute the public JWKS through the same channel you already trust for service identity, rotate keys by publishing a new card signed by both the old and new keys during the overlap, and treat "unsigned" or "no matching signature" as an explicit, visible state rather than a green checkmark.
A pre-delegation checklist
Before routing any real work to an agent, confirm:
- The card was fetched over HTTPS from the expected host, with no embedded credentials and no redirect chase.
-
nameand a non-emptyskillsarray parse cleanly, and the body stayed under the size cap. - At least one
supportedInterfacesentry matches a version and binding your client speaks, with a reachable URL. - Skill descriptions are specific enough to route on.
- The card is either signed by a key in your pinned JWKS, or the UI clearly marks it unsigned and untrusted.
Skipping the last line is the difference between service discovery and blindly following a stranger's map.
The Powerduck workspace implements this flow directly: load a card from its well-known URL, inspect skills and bindings, switch the active transport from a supported interface, and verify signatures against a JWKS you paste yourself, with the algorithm and key id shown on success. You can explore the spec-driven, local-first model behind it in the online demo, and the agent-facing design context in designing APIs for AI agents.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.