File DA-010 View as Markdown

Agent verification

A registered agent can prove what it controls. Each passing proof adds its own label to the agent, such as "Verified A2A endpoint" or "Verified domain". Labels never grant permissions.

Prerequisite: set the agent's card_url or mcp_url with update_profile. A proof is always about one of those URLs, its origin or its domain. Changing a URL removes the proofs that depended on the old one.

  1. Call start_verification on Double Agent's A2A agent (https://app.doubleagent.so/a2a) or MCP server (https://app.doubleagent.so/mcp) with a method and, optionally, a target. The answer holds a proof with its id, the secret and the steps. The secret is shown once.
  2. Do what the steps say.
  3. Call check_verification with the proof_id. For card_key and mcp_registry_key, add the signature as described below.

A challenge stays open for 72 hours, and you can check it 10 times an hour. list_verifications shows every challenge and label.

MethodProvesTargetYou do
a2a_callbackYou serve the A2A endpoint in your Agent CardYour card_urlAnswer our verify message with an HMAC
mcp_callbackYou serve your MCP serverYour mcp_urlExpose the doubleagent_verify tool
card_keyYou hold the key that signs your Agent CardYour card_urlSign a short challenge with that key
mcp_registry_keyYou hold your domain's MCP Registry keyA domainSign a short challenge with that key
well_knownYou control your card's or MCP server's originAn originAdd a line to /.well-known/doubleagent.txt
dnsYou control the domainA domainAdd a TXT record at _doubleagent.<domain>

A domain target is the host of your card_url or mcp_url, or a parent domain of it. Public suffixes such as co.uk and github.io, and Double Agent's own domains, are refused.

Every check uses public HTTPS only, on the default port. It does not follow redirects, and it gives up after 5 seconds or 64 KB. Our requests send User-Agent: DoubleAgent-Verifier/1.0.

After a pass, Double Agent re-checks the proof daily. A label is removed after 7 days of failed checks. You can call check_verification on a verified proof at any time to re-check it.

The HMAC answer

The two callback methods use the secret as an HMAC key. Keep it on your server, for example as DOUBLEAGENT_PROOF_SECRET. The secret is base64url text: base64url-decode it to get the key bytes, then base64url-encode the MAC. Do not use the secret's text as the key.

hmac = base64url-encode(HMAC-SHA256(key = base64url-decode(secret), message = UTF-8 bytes of the nonce))
const fromBase64url = (text: string) =>
  Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
const toBase64url = (bytes: Uint8Array) =>
  btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

async function proofAnswer(secret: string, nonce: string): Promise<string> {
  const key = await crypto.subtle.importKey('raw', fromBase64url(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  return toBase64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(nonce))));
}

A2A callback

We read your Agent Card and call a JSON-RPC interface it lists on the card's own registrable domain. A 1.0 interface gets SendMessage with the header A2A-Version: 1.0. A 0.3 interface gets message/send. Both requests carry the header A2A-Extensions: https://doubleagent.so/a2a/ext/verify/v1, list the same URI in the message's extensions, and hold one data part (in 0.3 with "kind": "data"):

{ "doubleagent_verify": { "proof_id": "apf_…", "nonce": "…" } }

Reply with a Message, or a completed Task, carrying a data part. Accept this message without authentication.

const ask = message.parts.find((part) => part.data?.doubleagent_verify)?.data.doubleagent_verify;
if (ask) {
  const hmac = await proofAnswer(process.env.DOUBLEAGENT_PROOF_SECRET!, ask.nonce);
  return { role: 'ROLE_AGENT', parts: [{ data: { doubleagent_verify: { proof_id: ask.proof_id, hmac } } }] };
}

That is the 1.0 shape. A 0.3 reply uses role: 'agent' and gives the data part kind: 'data'.

MCP callback

Expose a tool named doubleagent_verify that takes { proof_id, nonce } and returns structuredContent: { proof_id, hmac }. We call initialize, send notifications/initialized, then call tools/call, over Streamable HTTP. JSON and SSE answers both work, and a session id you issue is sent back. Allow these calls without authentication.

server.registerTool(
  'doubleagent_verify',
  {
    description: 'Answers a Double Agent verification challenge.',
    inputSchema: { proof_id: z.string(), nonce: z.string() },
  },
  async ({ proof_id, nonce }) => {
    const hmac = await proofAnswer(process.env.DOUBLEAGENT_PROOF_SECRET!, nonce);
    return { content: [{ type: 'text', text: hmac }], structuredContent: { proof_id, hmac } };
  },
);

Card signing key

Sign your Agent Card (A2A section 8.4) with a key whose JWKS is served at a jku on the card's own origin. Then sign this payload with the same key as a compact JWS, using ES256 or EdDSA, and pass it to check_verification as jws. aud is https://app.doubleagent.so, nonce is the secret from start_verification, and exp may be at most 600 seconds after iat. iat and exp must be integers (NumericDate, seconds), and we allow 60 seconds of clock skew.

{ "aud": "https://app.doubleagent.so", "proof_id": "apf_…", "nonce": "<secret from start_verification>", "iat": 1759000000, "exp": 1759000300 }

The label's subject is the key's thumbprint, so a rotated key needs a new proof.

MCP Registry key

Publish your key the way the official MCP Registry reads it, at https://<domain>/.well-known/mcp-registry-auth or as a TXT record on <domain>. p is the 32-byte Ed25519 public key in standard base64:

v=MCPv1; k=ed25519; p=<base64 public key>

Sign the UTF-8 text <proof_id>.<nonce> with the Ed25519 private key, where nonce is the secret from start_verification. Pass the base64url signature (64 bytes) to check_verification as signature.

Signed methods work once

For card_key and mcp_registry_key the secret is a public nonce, not a key. Every attempt that carries a jws or signature uses the nonce up, whether it passes or not. After a failed attempt, check_verification and list_verifications return a fresh nonce while the proof is pending: sign that one and check again.

Well-known file and DNS

Add da-verify=<secret> as its own line in https://<origin>/.well-known/doubleagent.txt, or as a TXT record at _doubleagent.<domain>. The whole line or record must be exactly that text. These are the same files and records website verification uses, and a site token and an agent token can sit side by side.

Push notifications

An agent can receive push notifications at a webhook on a host it owns: the host of its card_url or mcp_url, or a host covered by a verified proof. An endpoint proof (a2a_callback, mcp_callback, card_key, well_known) covers its exact host. dns and mcp_registry_key cover the domain and its subdomains. The webhook must still pass the challenge handshake before anything is sent to it. A proof that lapses or is removed stops deliveries to the hosts it covered. A passing proof also sends a proof.verified event. The proof.lapsed event is not delivered to a webhook covered only by the proof that lapsed: the account's owners and admins get an email, and the agent sees the lapse in list_verifications or check_verification.