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.
- Call
start_verificationon Double Agent's A2A agent (https://app.doubleagent.so/a2a) or MCP server (https://app.doubleagent.so/mcp) with amethodand, optionally, atarget. The answer holds aproofwith itsid, thesecretand the steps. The secret is shown once. - Do what the steps say.
- Call
check_verificationwith theproof_id. Forcard_keyandmcp_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.
| Method | Proves | Target | You do |
|---|---|---|---|
a2a_callback | You serve the A2A endpoint in your Agent Card | Your card_url | Answer our verify message with an HMAC |
mcp_callback | You serve your MCP server | Your mcp_url | Expose the doubleagent_verify tool |
card_key | You hold the key that signs your Agent Card | Your card_url | Sign a short challenge with that key |
mcp_registry_key | You hold your domain's MCP Registry key | A domain | Sign a short challenge with that key |
well_known | You control your card's or MCP server's origin | An origin | Add a line to /.well-known/doubleagent.txt |
dns | You control the domain | A domain | Add 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.