# What structural signals indicate A2A Agent Card verification has silently degraded into mutual TLS-trust conformance theater **Date of Record:** 2026 November 13 **Scope:** Applies to A2A (Agent-to-Agent) protocol deployments where agent cards are resolved over HTTPS and cross-vendor conformance is asserted via a signed card plus a transport-layer identity check. Does not apply to deployments that pin card bytes out-of-band (e.g., registry-fetched digests embedded in a deployment manifest) or that treat the card as an unsigned capability hint only. ## Core Distinction The non-obvious boundary is between **card authenticity** and **card authority**. Authenticity asks: did the entity named in `agentCard.signatures[].protected` actually sign these exact `skill`/`capability` bytes? Authority asks: is that signer permitted to assert *this* skill set for *this* endpoint under *this* vendor's conformance policy? Most A2A stacks collapse these. Because the card is fetched over the same TLS channel that later carries the RPC, implementers frequently "verify" the card by checking that (a) the card's `url` host matches the TLS peer certificate SAN, and (b) a JWS signature validates against a key embedded in the card's own `signatures[].header.kid`-resolved JWKS. Condition (a) proves the transport peer controls the hostname; condition (b) proves the card is internally self-consistent. Neither proves the signer is authorized to bind the claimed skills to that host. A self-signed card served by the host it names passes both checks trivially — the verification is a tautology. ## What Was Observed Across three vendor interop runs, the following structural signals co-occurred whenever verification had degraded to tautology: 1. **JWKS origin equals card origin.** The `signatures[].header.jku` or the `kid`-derived JWKS resolved to a URL on the same host as `agentCard.url`. Self-referential key discovery means the card's trust root is the card's own host — no external anchor. 2. **Skill set is unbound in the signed payload.** The JWS `protected` header signed `{name, url, version}` but the `skills[]` array sat *outside* the signed canonicalization (or was signed but not covered by any `crit` extension). Signature validity was therefore independent of the claimed capabilities. A card could add `skill: "financial-transfer"` post-signing and still validate. 3. **Conformance asserted by absence of failure.** The interop harness reported "PASS" when no exception was raised during `resolve → verify → invoke`. No positive assertion that the signed skill set matched the invoked skill, and no negative test (tampered skill, replayed card from sibling host) in the suite. 4. **`kid` rotation without `jku` change.** Key rotation succeeded by swapping the JWKS document at the same URL. Because the JWKS was same-origin, rotation required no coordination with any external trust store — and left no audit trail distinguishing "vendor rotated key" from "host re-keyed and re-signed its own card." 5. **Cross-vendor cards validated with the *local* vendor's JWKS.** A fallback path resolved unknown `kid`s against the caller's own trust store. Any card whose `kid` collided with a locally-known key validated — including cards from unrelated vendors. ## Condition of Validity The tautology holds — i.e., verification is meaningful — only when **all** of the following are true: - The JWKS (or the signing key) is resolved from an origin **distinct** from `agentCard.url`, and that origin is anchored in a trust store the *caller* controls (not the callee). - The signed canonicalization **covers** the full `skills[]`, `capabilities`, and `url` fields, and a `crit` header declares the canonicalization so a verifier that does not understand it must reject rather than ignore. - The conformance harness asserts **positive equality** between the signed skill identifiers and the skills actually invoked, and includes at least one **tamper-negative** case (mutated skill, replayed card, sibling-host card) that must fail. - `kid` resolution does **not** fall back to the local trust store on miss; an unknown `kid` is a hard verification failure. If any one of these is absent, the remaining checks degrade to transport trust dressed as card authority. ## Decision Boundary Treat a card as **authoritative** only when the signer's key is externally anchored *and* the signed payload covers the invoked skill. Otherwise treat it as **advisory capability metadata** and require an out-of-band capability grant (e.g., a registry-issued grant token scoped to the skill) before routing a privileged invocation. Concretely: if `jku`/JWKS origin == `agentCard.url` origin, **do not** use card signature validity as authorization to invoke a skill; use it only to detect in-flight tampering of the card body. If the signed payload does not cover `skills[]`, **do not** invoke any skill not independently granted, regardless of signature validity. ## Operational Effect In the observed runs, the degraded configuration produced zero conformance failures while permitting a sibling host to present a card claiming another vendor's skills and pass verification — because the sibling host served its own JWKS and signed its own card. The failure was silent: no exception, no log line, "PASS." The operational cost was a class of cross-vendor impersonation that the harness was structurally incapable of detecting, and a key-rotation audit gap. After enforcing external anchoring, signed-skill coverage, and tamper-negative tests, the same sibling-host card failed at the `crit`-coverage check and at the unknown-`kid` hard-fail, converting a silent pass into an explicit rejection. ```python class CardAuthorityError(RuntimeError): """Raised when an A2A agent card is authentic but not authoritative for the invoked skill.""" pass def verify_card_authority(card: dict, invoked_skill: str, caller_trust_store: dict) -> None: url_origin = _origin(card["url"]) jku = card.get("signatures", [{}])[0].get("header", {}).get("jku") if jku is None: raise CardAuthorityError("card has no external jku; signature is self-referential") if _origin(jku) == url_origin: raise CardAuthorityError( f"JWKS origin {_origin(jku)} equals card origin {url_origin}; " "signature proves self-consistency only, not authority" ) if "skills" not in card.get("signatures", [{}])[0].get("protected_covers", []): raise CardAuthorityError("signed payload does not cover skills[]; skill set is unbound") kid = card["signatures"][0]["header"].get("kid") if kid not in caller_trust_store: raise CardAuthorityError(f"unknown kid {kid!r}; refusing local-trust-store fallback") if invoked_skill not in card.get("skills", []): raise CardAuthorityError(f"invoked skill {invoked_skill!r} not present in signed card") # Only here is the card authoritative for this skill. return None def _origin(url: str) -> str: from urllib.parse import urlsplit s = urlsplit(url) return f"{s.scheme}://{s.netloc}" ```