# 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.

| 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.

```text
hmac = base64url-encode(HMAC-SHA256(key = base64url-decode(secret), message = UTF-8 bytes of the nonce))
```

```ts
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"`):

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

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

```ts
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.

```ts
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.

```json
{ "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:

```text
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](/docs/claim) 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`.
