Your Agent Started Calling Other Agents. Who Verified Theirs?
A2A hit v1.0 this year and teams are wiring their agents to other teams' and vendors' agents. The moment your agent fetches an Agent Card and acts on it, you have extended your trust boundary to identity and code you do not control. The spec advertises auth schemes but leaves verification to you. Here is the admission gate that makes an outbound A2A call safe: verify the signed card, scope the credential you hand over, and block replays.
Sometime this year your agent stopped being the only agent in the room. A2A reached v1.0 and moved from experiment to something teams actually ship, so the natural next step happened: your agent now calls another team's agent, or a vendor's, to get a thing done it cannot do alone. It fetches an Agent Card from a well-known URL, reads what the other agent can do and how to authenticate, and starts making calls. That is the whole promise of interoperability, and it works.
It also quietly moved your trust boundary. Up to this point everything your agent could reach was code you wrote, tools you registered, and credentials you controlled. The instant it acts on a document it fetched from someone else's host, it is trusting identity and behavior that live outside your system. The A2A spec knows this. It gives you signed Agent Cards, per-skill security schemes, and a place to declare auth. What it does not do is make you use any of it. Verification is delegated to the implementer, which means it is delegated to you, which means on most teams right now it is not happening at all.
This is a boundary problem, the same shape as the confused-deputy risk inside a single agent and the supply-chain risk of running third-party skills. The fix is the same shape too. You put an admission gate in front of every outbound A2A call and you decide, in code, three things: is this card really from who it claims to be, what credential am I willing to hand over, and can this exact request be replayed against me. Let me walk through each one.
What an Agent Card actually is, and why fetching it is not trusting it
An Agent Card is a JSON document a remote agent serves, by convention at /.well-known/agent-card.json. It advertises the agent's name and provider, the skills it offers, the endpoints to call, and a securitySchemes block in OpenAPI format declaring how you are meant to authenticate (OAuth2, OIDC, API key, or mutual TLS). Optionally it carries a signatures array: one or more JSON Web Signatures computed over a canonicalized form of the card, so tampering with a description or, more to the point, an endpoint URL, breaks the signature.
Here is the trap. You can fetch that card over perfect HTTPS and still know nothing about whether to trust it. HTTPS authenticates the transport to a hostname. It does not tell you that hostname is authorized to speak for "Acme Billing Agent," that the card was not swapped before it was served, or that the url field inside the card points at an endpoint the real provider controls rather than an attacker's collector. A card that says it wants an API key and points its endpoint at a host you have never heard of is a phishing page with a schema. So the first thing the gate does is refuse to read any field of the card as meaningful until the signature verifies against a key you obtained out of band.
import { importJWK, flattenedVerify } from "jose";
import { canonicalize } from "./jcs"; // RFC 8785 JSON Canonicalization
// Issuer public keys, pinned out of band (from a contract, a key registry,
// an onboarding step). Never fetch these from the same host that served the
// card, or the card is just vouching for itself.
const TRUSTED_ISSUER_KEYS: Record<string, JsonWebKey> = {
"acme-agents-2026": { kty: "OKP", crv: "Ed25519", x: "u9x...J0" },
};
export async function verifyAgentCard(card: Record<string, unknown>) {
const { signatures, ...unsigned } = card as { signatures?: any[] };
if (!signatures?.length) return { ok: false, reason: "unsigned" };
// The signature covers the canonicalized card WITHOUT the signatures field.
// Rebuild that exact byte sequence; a stray key or reordered field fails it.
const payload = new TextEncoder().encode(canonicalize(unsigned));
for (const sig of signatures) {
const header = JSON.parse(
Buffer.from(sig.protected, "base64url").toString("utf8"),
);
const jwk = TRUSTED_ISSUER_KEYS[header.kid];
if (!jwk) continue; // unknown signer, this signature proves nothing
try {
const key = await importJWK(jwk, header.alg);
// Detached payload: pass the bytes we reconstructed above.
await flattenedVerify({ ...sig, payload }, key);
return { ok: true, issuer: header.kid };
} catch {
// Tampered, wrong key, or wrong algorithm. Try the next signature.
}
}
return { ok: false, reason: "no trusted signature" };
}
Two details do the real work. The keys are pinned out of band, not discovered from the card or its host, because a signature is only as good as your independent knowledge of the signer. And you canonicalize before you verify, because the signature was computed over a normalized byte sequence; if you hash the raw response instead, an attacker who reorders fields or adds whitespace slips past you or, more likely, you get false failures and give up and disable the check. Use the canonicalization the spec calls for and this stays boring.
The gate: verified card in, policy decision out
Verification tells you the card is authentic. It does not tell you the card is acceptable. Those are different questions. A perfectly signed card can still declare an auth scheme weaker than your policy allows, point at a new endpoint since the last time you looked, or ask for a scope you never agreed to grant. So the gate takes the verified card and runs it against your policy before anything talks to the network.
import { createHash } from "node:crypto";
interface Pin {
cardHash: string; // hash of the exact card you reviewed and approved
allowedHosts: string[]; // endpoints you will actually call
requireScheme: "oauth2" | "mutualTLS"; // no api-key-in-a-header agents
}
// One pinned, human-approved record per remote agent you integrate with.
const REGISTRY: Record<string, Pin> = {
"acme-billing": {
cardHash: "sha256:9f2c...e1",
allowedHosts: ["agents.acme.example"],
requireScheme: "oauth2",
},
};
export async function admitAgent(agentId: string, rawCard: Record<string, unknown>) {
const pin = REGISTRY[agentId];
if (!pin) throw new Error(`no registration for ${agentId}`);
const verdict = await verifyAgentCard(rawCard);
if (!verdict.ok) throw new Error(`card rejected: ${verdict.reason}`);
// A signed card can still be a NEWER card than the one you reviewed.
// Pinning the hash turns a silent endpoint or scope change into a stop.
const hash = "sha256:" + createHash("sha256")
.update(canonicalize(rawCard))
.digest("hex");
if (hash !== pin.cardHash) {
throw new Error("card changed since review; needs re-approval");
}
const endpoint = new URL(String((rawCard as any).url));
if (!pin.allowedHosts.includes(endpoint.host)) {
throw new Error(`endpoint ${endpoint.host} not on allowlist`);
}
if (!(rawCard as any).securitySchemes?.[pin.requireScheme]) {
throw new Error(`agent does not offer ${pin.requireScheme}`);
}
return { endpoint, scheme: pin.requireScheme };
}
The pinned hash is the piece people skip, and it is the piece that saves you. Signature verification proves the card is genuinely from Acme. It does not prove it is the same card you read when you decided Acme was safe to call. Providers rotate endpoints and loosen schemes, sometimes for good reasons and sometimes because someone made a mistake. Pinning the hash of the reviewed card means any change, benign or not, stops the call and asks a human to look, instead of silently redirecting your traffic. This is the same discipline as pinning the exact bytes of a skill you reviewed rather than trusting whatever the registry serves next.
Do not hand over your own identity
Now the call is admitted and you need to authenticate. The tempting move, the one I see most, is to forward the access token your service already holds. It is right there and it works. It is also the confused-deputy problem wearing a name tag. That token carries all of your scopes. The moment you send it to another agent, that agent can act as you against everything the token unlocks, for as long as it lives, well beyond the single operation you intended. If that agent is later compromised, or just logs its inputs somewhere careless, your whole identity leaked through one interop call.
Mint a fresh credential scoped to the one thing this call needs. OAuth 2.0 token exchange exists for exactly this: you trade your token for a new one that is restricted to the remote agent as its audience and to a single scope, and that expires in minutes.
// Exchange your ambient token for a narrow, short-lived, audience-bound one.
// The remote agent receives only what THIS skill needs, and only briefly.
async function mintDelegatedToken(scope: string, audience: string) {
const res = await fetch(process.env.TOKEN_ENDPOINT!, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: process.env.SERVICE_TOKEN!,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
audience, // the remote agent, so the token is useless elsewhere
scope, // e.g. "billing:read", never your full scope set
}),
});
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
const { access_token, expires_in } = await res.json();
return { access_token, expires_in };
}
If the remote agent is breached with one of these tokens in hand, the damage is one scope for a few minutes against one audience, not a standing grant to impersonate your service. That is the difference between an incident and a headline.
Stop the same request from being replayed
The last gap is subtle. Even a legitimate, correctly scoped request can be captured and sent again, either by a compromised intermediary or by the remote side retrying in a way you did not intend. If your A2A calls have side effects, and billing, provisioning, and messaging agents all do, a replay is a duplicate action. Bind each request to a nonce and a timestamp, sign that binding, and have the receiver reject anything stale or already seen. If you own both ends this is straightforward; if you own only the caller, at minimum make your own side effects idempotent so a replay your peer causes cannot double-charge you.
import { randomUUID } from "node:crypto";
// Attach a per-request nonce and timestamp. The receiver keeps a short-lived
// set of seen nonces and rejects duplicates and anything outside a small window.
function withAntiReplay(body: object) {
return {
...body,
_nonce: randomUUID(),
_issuedAt: Date.now(), // receiver rejects if now - issuedAt > 60s
};
}
Tradeoffs and the things that bite
Where the keys come from is the whole game. Pinned issuer keys are strong but operationally real: you need a way to receive and rotate them, and a plan for the first contact. Trust-on-first-use is easier and much weaker, because the first fetch is exactly when an attacker wants to be in the middle. If you use it, pin the hash immediately after and treat any later change as suspect.
Verification is not a one-time event. Cards are living documents. Verify on a TTL, not once at onboarding, or you will be trusting a snapshot from months ago. The pinned hash is what makes re-verification meaningful instead of a rubber stamp.
An authentic agent can still behave badly. All of this proves who you are talking to and limits what they can do to you. It says nothing about whether their output is correct or safe to act on. A verified agent can still return a hallucinated invoice or a poisoned instruction, so its response is untrusted input to your system and belongs behind the same output guardrails and grounding checks you apply everywhere else.
Clock skew and nonce storage. Anti-replay windows are only as good as your clocks and your seen-nonce store. Keep the window tight but not so tight that normal latency trips it, and make sure the nonce store survives a restart or your protection has a gap every deploy.
The takeaway
A2A did not create a new class of risk. It took the trust boundary you were already managing inside one agent and stretched it across an organizational line, where the other side is genuinely not yours. The protocol hands you the tools to manage that, signed cards, per-skill schemes, declared auth, and then steps back and lets you decide whether to use them. The gate is a couple hundred lines: verify the card against a key you pinned, refuse anything that drifted from what you reviewed, hand over a credential scoped to one call, and make replays inert. Wire it as the single path for outbound A2A traffic and an unverified agent call becomes structurally impossible rather than merely discouraged.
If your agents are starting to call other teams' or vendors' agents and nobody owns the question of what gets verified before that call goes out, that gap is where a quiet impersonation or an over-scoped token turns into a real incident. Book a consultation call and we can map your agent-to-agent boundaries and put a gate on the ones that matter.
Viral Ruparel
Generative AI consultant helping teams ship reliable LLM and agent systems in production.
Contact Viral about your AI project →Frequently Asked Questions
Do I have to verify the Agent Card if I fetched it over HTTPS?+
Yes. HTTPS tells you the bytes came from the host you connected to and were not changed in transit. It says nothing about whether that host is allowed to speak for the agent it claims to be, whether the card was tampered with before it was served, or whether the endpoint URL inside the card points somewhere you trust. The A2A spec supports signed Agent Cards precisely because transport security is not identity. Verify the JWS signature against an issuer key you pinned out of band, then check that the declared endpoint and security scheme match what you expected before you send a single request.
Should I forward my own access token to the remote agent?+
No. Forwarding your ambient credential is the confused-deputy problem dressed up as interoperability. The remote agent now holds a token with all of your scopes and can act as you against every system that token unlocks, long after the one call you meant to make. Mint a fresh, audience-restricted, short-lived credential per call that carries only the scope the specific skill needs. OAuth 2.0 token exchange is built for exactly this. If the remote agent is compromised, the blast radius is one scope for a few minutes, not your whole identity.
The card checked out once. Do I need to verify it again?+
Verify on a schedule, not once forever. An Agent Card is a live document the provider can change, so a card you verified at onboarding can be rotated to a new endpoint or a weaker auth scheme the next day. Cache the verified card with a short TTL and re-verify when it expires, and pin the hash of the exact card you reviewed so a silent change becomes a reviewable event rather than an invisible one. Treat a card whose signature or endpoint changed as untrusted until a human or a policy approves the new version.
Related Articles
Your Model Started Showing Its Work. Now You Have to Handle It.
Reasoning traces are the feature everyone turned on this month and nobody planned for. Handled wrong, they quietly triple your token bill, dump intermediate reasoning full of customer data into your logs, and break your tool loop in a way that looks like the model got dumber. Here is how to treat extended thinking as something you manage at the boundary, not something you print.
As of This Month, EU Law Says Your AI Output Has to Mark Itself
Article 50 of the EU AI Act took effect on August 2, and it says any image, audio, video, or text your system generates has to carry a machine-readable mark that it was made by AI. A checkbox in your UI does not satisfy that. The mark has to live inside the file, survive a re-upload, and be verifiable. Here is how to build that into your generation pipeline as a gate, not a bolt-on.
Your Agent Called the Same Tool Seventy Times and Billed You for It
A ReAct-style agent calls the same search tool, gets the same unhelpful result, decides another identical call will help, and does it again. Seventy times. It never crashes and never finishes, it just burns tokens going nowhere until a timeout or your bill catches it. A hard step ceiling is a backstop, not a fix. What you want is a guard that notices the agent has stopped making progress and breaks the cycle in seconds.