An A2A client learns everything about a remote agent from its Agent Card: where to send requests, which security scheme to use, which skills exist. If an attacker can substitute that card, by poisoning DNS, compromising a CDN, or simply registering a look-alike domain in a registry, the orchestrator will happily send customer data to the wrong place and trust whatever comes back. Verifying agent identity is the work of making that substitution detectable before the first message leaves your JVM, and of knowing which agent is calling you when you are the server.

This article builds the verification path in Java for an ADK Java deployment. It does not re-explain the card format, which is covered field by field in the A2A Agent Card specification article, or the choice between allowlists, PKI and registries, covered in A2A trust models. It focuses on code: verifying a signed card correctly, binding the card to the endpoint you actually reach, authenticating inbound callers, rotating keys, and the failure modes that let a check pass while proving nothing.

Three identity questions, three checks

Teams routinely answer one of three questions and assume the other two. Is this card authentic, produced by the party you trust and unmodified? A JWS signature answers that. Is the endpoint you are about to call the agent the card describes? A signature cannot answer that alone, because anyone can copy a genuine signed card and serve it from their own host; the answer comes from binding the URLs inside the signed card to the TLS identity of the server you reach. And when you are the server, who is calling? The caller presents a credential, usually an OAuth 2.0 access token, which you validate.

The A2A 1.0 specification supplies the mechanisms: TLS server validation (section 7.2) and JWS card signatures over RFC 8785 canonical JSON (section 8.4). What it leaves to you is policy: which keys you trust, for which domains, and what happens when verification fails.

Three identity questions in one A2A callOrchestrator (client)ADK Java root agentRemote agent (server)A2A JSON-RPC endpoint1. GET /.well-known/agent-card.json3. message/send + bearer tokenQ1: Is the card authentic?JWS over JCS, pinned keyQ2: Is this endpoint that agent?card URL host = TLS cert hostQ3: Who is calling me?token iss, aud, sender bindingEach answer alone is insufficienta valid signature does not prove the peer holds the signing keyClient-side checks answer Q1 and Q2 before any request; server-side checks answer Q3 on every request.
Card authenticity, endpoint binding and caller identity are separate checks with separate failure modes.

Verifying a signed Agent Card in Java

Each AgentCardSignature carries a base64url protected header, a base64url signature, and an optional unprotected header object. The protected header must carry alg, typ and kid, and may carry a jku URL pointing at a JWKS. The signature is detached: the payload is not inside the signature object, it is the card itself after processing. The JWS signing input is the usual BASE64URL(protected) + '.' + BASE64URL(payload).

The step most hand-rolled verifiers get wrong is normalization. The specification says the signed form follows protobuf field-presence rules: required fields are always present, fields declared optional are present exactly when they were set, and other fields holding a default value (an empty list, an empty string, false, zero) are omitted. Verification repeats that processing on the received card. If a publisher's serializer emits "extensions": [] while its signer worked from the normalized form, a verifier that canonicalizes the raw JSON will compute a different payload and reject a genuine card, and the usual reaction is a flag that skips verification. Put the normalization in code.

The example below uses Jackson for the tree, the io.github.erdtman:java-json-canonicalization library (class org.erdtman.jcs.JsonCanonicalizer) for RFC 8785, and Nimbus JOSE+JWT for the JWS check. I could not confirm that the a2a-java SDK ships a card verifier; if your version does, prefer it and keep the policy below.

Card verification pipeline in the clientFetch cardHTTPS, hostname checkedDetachremove signatures[]Normalizepresence rulesCanonicalizeRFC 8785 (JCS)Verify JWSkid in pinned trust store, alg allowlistBindinterface hosts within key's domainsBuild A2A clientonly from verified cardAny step fails: reject, alert, keep last goodnever fall back to unsignedThe order matters: binding runs on the verified bytes, never on the raw JSON you fetched.
Verify, then bind, then build the client from the verified card only.
public final class AgentCardVerifier {
  private static final ObjectMapper MAPPER = new ObjectMapper();
  private static final Set<JWSAlgorithm> ALGS = Set.of(JWSAlgorithm.ES256);  // EdDSA needs Tink
  // Paths of REQUIRED or `optional` proto fields, kept even at default values (A2A 1.0 AgentCard
  // and AgentCapabilities; add nested skill and interface fields from the proto you target).
  private static final Set<String> KEEP = Set.of("name", "description", "version",
      "supportedInterfaces", "capabilities", "defaultInputModes", "defaultOutputModes", "skills",
      "documentationUrl", "iconUrl", "capabilities.streaming",
      "capabilities.pushNotifications", "capabilities.extendedAgentCard");

  private final TrustStore trust;   // kid -> (public JWK, allowed host suffixes); pinned, not fetched

  public VerifiedCard verify(String body, URI fetchedFrom) throws CardRejected {
    ObjectNode card;
    try { card = (ObjectNode) MAPPER.readTree(body); }
    catch (IOException e) { throw new CardRejected("unparseable card"); }
    JsonNode sigs = card.remove("signatures");
    if (sigs == null || !sigs.isArray() || sigs.isEmpty()) throw new CardRejected("unsigned card");

    byte[] canonical;
    try {
      String normalized = MAPPER.writeValueAsString(normalize(card, ""));
      canonical = new JsonCanonicalizer(normalized).getEncodedString().getBytes(UTF_8);
    } catch (IOException e) { throw new CardRejected("canonicalization failed"); }

    for (JsonNode s : sigs) {
      try {
        JWSObject jws = new JWSObject(new Base64URL(s.path("protected").asText()),
            new Payload(canonical), new Base64URL(s.path("signature").asText()));
        JWSHeader h = jws.getHeader();
        TrustedKey key = trust.byKid(h.getKeyID());          // ignore jku: attacker-supplied
        if (key == null || !ALGS.contains(h.getAlgorithm())) continue;
        if (!jws.verify(key.verifier())) continue;
        bind(card, fetchedFrom, key);                        // throws CardRejected
        return new VerifiedCard(card, h.getKeyID());
      } catch (ParseException | JOSEException e) {
        // malformed entry: try the next signature
      }
    }
    throw new CardRejected("no signature verified with a trusted key");
  }

  private static JsonNode normalize(JsonNode n, String path) {   // array elements share a path
    if (n.isObject()) {
      ObjectNode out = MAPPER.createObjectNode();
      n.fields().forEachRemaining(e -> {
        String p = path.isEmpty() ? e.getKey() : path + "." + e.getKey();
        JsonNode v = normalize(e.getValue(), p);
        if (KEEP.contains(p) || !isDefault(v)) out.set(e.getKey(), v);
      });
      return out;
    }
    if (n.isArray()) {
      ArrayNode out = MAPPER.createArrayNode();
      n.forEach(x -> out.add(normalize(x, path)));
      return out;
    }
    return n;
  }

  private static boolean isDefault(JsonNode v) {
    return (v.isContainerNode() && v.isEmpty()) || (v.isTextual() && v.asText().isEmpty())
        || (v.isBoolean() && !v.asBoolean()) || (v.isNumber() && v.asDouble() == 0.0);
  }
}

Three design choices in that code are deliberate. Keys come from a pinned trust store keyed by kid, not from the jku URL in the header: the header is data supplied by whoever served the card, so following jku lets an attacker name their own key server and sign with their own key. If you want to use jku, accept it only when it matches an exact allowlisted URL, and fetch it with the same TLS checks as the card. The algorithm allowlist stops downgrade games such as none or an HMAC algorithm keyed with a public key. And one good signature from a trusted key is enough, which lets a publisher carry two signatures during a key rotation.

KEEP is a placeholder to derive from the protocol definition, not a list to copy. Test the verifier against cards signed by your partners' SDKs, not only your own.

Binding the card to the endpoint

A verified signature means the card's contents can be believed; binding turns that into a statement about the endpoint. Every URL in supportedInterfaces must use HTTPS with a host inside the domains the signing key is authorized for, and so must the origin the card came from. The other half is TLS: the JDK HttpClient verifies the certificate chain and hostname, so a connection to orders.supplier.example only succeeds against a certificate for that name. Never set the jdk.internal.httpclient.disableHostnameVerification system property outside local tests; it silently removes the half of the binding that defeats a copied card.

private void bind(ObjectNode card, URI fetchedFrom, TrustedKey key) throws CardRejected {
  Set<String> hosts = new HashSet<>();
  for (JsonNode iface : card.path("supportedInterfaces")) {
    URI u = URI.create(iface.path("url").asText());
    if (!"https".equals(u.getScheme())) throw new CardRejected("non-HTTPS interface " + u);
    if (!key.allowsHost(u.getHost())) throw new CardRejected(u.getHost() + " outside key scope");
    hosts.add(u.getHost());
  }
  if (hosts.isEmpty()) throw new CardRejected("card declares no interfaces");
  if (!hosts.contains(fetchedFrom.getHost()) && !key.allowsHost(fetchedFrom.getHost()))
    throw new CardRejected("card served from unexpected origin " + fetchedFrom.getHost());
}

Scoping keys to domains is the policy that makes signing worth having. A supplier's key should be able to vouch for *.supplier.example and nothing else; otherwise a compromise of one partner's key lets the attacker mint cards for every agent you call. For agents inside your own organization, the same binding is often expressed through workload identity, as described in mTLS for service-to-service; the card signature then protects the metadata the handshake does not cover, such as advertised security requirements.

Verifying the caller on inbound requests

When your ADK Java agent is the server, validate the credential its securitySchemes ask for on every request, before the executor runs: signature against the issuer's keys, issuer, audience (your agent, so a token minted for another agent is useless here) and expiry. Nimbus provides the processor:

ConfigurableJWTProcessor<SecurityContext> proc = new DefaultJWTProcessor<>();
JWKSource<SecurityContext> idpKeys =
    JWKSourceBuilder.create(new URL("https://idp.example.com/.well-known/jwks.json")).build();
proc.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, idpKeys));
proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
    "https://orders.supplier.example/a2a",                          // required audience
    new JWTClaimsSet.Builder().issuer("https://idp.example.com").build(),
    Set.of("sub", "exp", "iat", "client_id")));

JWTClaimsSet claims = proc.process(bearerToken, null);              // throws on any failure

// Sender constraint (RFC 8705): token bound to the client certificate on this connection.
Map<String, Object> cnf = claims.getJSONObjectClaim("cnf");
if (cnf == null) throw new Unauthorized("token is not sender-constrained");
String bound = (String) cnf.get("x5t#S256");
String presented = Base64URL.encode(sha256(clientCert.getEncoded())).toString();
if (bound == null || !MessageDigest.isEqual(bound.getBytes(UTF_8), presented.getBytes(UTF_8)))
  throw new Unauthorized("token not bound to presenting client");
CallerIdentity caller = new CallerIdentity(claims.getStringClaim("client_id"), claims.getSubject());

Agents leak bearer tokens into traces, prompts and tool arguments; sender-constrained tokens make a leaked one useless. With mutual-TLS-bound tokens (RFC 8705) the token's cnf claim carries a thumbprint of the client certificate, as above; with DPoP (RFC 9449) the client proves possession of a key on each request. Pass the resulting CallerIdentity into session state so tools and policy can see it, and keep caller identity separate from the end user the request is on behalf of; authorization at the agent boundary covers how to model those two identities in policy.

Where this sits in ADK Java

In ADK Java the remote agent is consumed through the google-adk-a2a module, where RemoteA2AAgent wraps the A2A SDK client and resolves the card when it is built; the moving parts are described in ADK Java and A2A. The safe integration point is before construction: fetch the card yourself, run AgentCardVerifier, and hand only the verified card to whatever builder your SDK version exposes, so nothing downstream ever sees unverified bytes. If your version only accepts a URL and resolves the card internally, verify the same URL first and compare the card the client resolved against the verified one at startup, failing the boot if they differ.

Re-verify on every refresh, not only at boot, and run an authenticated extended card through the same verifier before it replaces the public one for the session.

Key rotation and last-known-good cards

Keys rotate and publishers make mistakes, and a strict verifier turns both into outages. Rotate in order: add the new key to consumers' trust stores, sign with both keys for an overlap window, drop the old signature, remove the old key. Consumers cache the last verified card per remote and keep using it when a refresh fails verification, while alerting. That serves a card you already verified, never an unverified one, and it expires after a bounded period (a day suits most partners), after which calls fail closed.

EventVerifier behaviourSignal
Refresh returns unsigned cardKeep last good, alertcard_verify_fail{reason=unsigned}
Unknown kid or bad signatureKeep last good, page security on-callreason=bad_signature
Interface host outside key scopeReject, page: likely substitutionreason=binding
Last good older than max ageFail closed, remote marked unavailablecard_age_seconds

Worked example: a look-alike supplier agent

Take a procurement orchestrator built on ADK Java that delegates quotes to a supplier's agent at https://orders.supplier.example/a2a. The supplier publishes a signed card with kid sup-2026-09; your trust store maps that kid to the supplier's public key and to the host suffix supplier.example.

Now an attacker registers supp1ier.example, gets it into an internal registry, and serves the genuine signed card with the interface URL pointed at their own host. The signature check fails, because the payload changed. Suppose instead they serve the genuine card unchanged from their host: the signature verifies, but binding fails, since the card was fetched from an origin outside supplier.example. Suppose they poison DNS for the real hostname: the card verifies and binds, but the TLS handshake to orders.supplier.example fails because they hold no certificate for it. Each check stopped a different attack; removing any one reopens a path.

Failure modes

Failure modeWhy it passes silentlyFix
Verifier follows jkuAttacker's key signs attacker's cardPinned keys by kid; exact-URL allowlist if jku is used
Canonicalizing raw JSONGenuine cards fail, verification gets switched offExplicit presence-rule normalization, cross-SDK tests
Signature checked, binding skippedCopied genuine card from attacker host passesInterface hosts within the key's domain scope
Hostname verification disabledDNS hijack passes TLSFail the build on the system property
Bearer tokens without audience checkToken for agent A replays at agent BRequired audience per agent; sender-constrained tokens

What to do next

  1. List every remote agent your ADK Java services call and record, for each, how its card is fetched today and whether it is signed.
  2. Create a pinned trust store mapping kid to public key and allowed host suffixes, versioned in your repository with review on every change.
  3. Implement the verifier with explicit normalization, an algorithm allowlist and no jku following, and test it against cards signed by each partner's SDK.
  4. Add the binding check and a startup assertion that the card your A2A client resolved equals the verified card.
  5. On the server side, validate issuer, audience and expiry on every request and move to mTLS-bound or DPoP tokens where your identity provider supports them.
  6. Add last-known-good caching with a maximum age, metrics per failure reason, and a written rotation procedure you rehearse with one partner.
  7. Review the trust options in A2A trust models before adding a registry, so the registry's key is scoped as tightly as a partner's.
Key takeaway: Agent identity in A2A is three checks, not one. Verify the card's JWS signature against a pinned key after normalizing and canonicalizing exactly as the specification describes; bind the URLs inside the verified card to domains that key may vouch for and let TLS hostname verification prove you reached them; and on the server, validate issuer, audience, expiry and ideally a sender binding on every request. Cache the last verified card for bounded periods, never fall back to an unsigned one.