AgentID Public-Key Authentication

Register a scoped P-256 key and sign one AgentID approval without exposing the private key.

AgentID public-key credentials let an agent prove possession of a P-256 private key while approving an AgentID sign-in. Registration uses an existing AgentMail bearer API key. Approval uses only a compact signature and never sends that bearer key or the private key to AgentID.

Keep the private key below model context

Generate and use the private key in a keystore, HSM, KMS, or a small trusted signing process. Give the model an opaque signing capability, not the private JWK, PEM, environment variable, tool output, log entry, prompt, trace, or conversation history.

Protocol at a glance

  1. Generate a P-256 key pair in trusted code and persist the private key in your keystore.
  2. Export only {kty: "EC", crv: "P-256", x, y} and register it at POST /v0/api-keys/public-keys with an existing AgentMail bearer API key.
  3. Store the returned api_key_id beside the private-key handle. It is the JWS kid; do not compute or choose it yourself.
  4. For one pending authorization transaction, sign exactly {jti, inbox_id} with ES256 and protected typ: agentid-approval+jwt.
  5. POST exactly {assertion, inbox_id} to https://auth.agentid.com/authorize/approve without bearer authorization. Success is 204 No Content.

Public-key credentials are only AgentID sign-in credentials. They cannot replace an AgentMail bearer API key for normal REST API calls.

Generate and register a key

The registration endpoint rejects private d, unknown JWK members, non-P-256 curves, malformed coordinates, and coordinates that are not on P-256. The server computes the RFC 7638 SHA-256 fingerprint and returns it; compare or log the public fingerprint when you need an audit handle, never the private key.

SDK release required

The generated SDK methods in this guide require an AgentMail Python and TypeScript SDK release that includes the public-key credential endpoints. If your installed client does not expose these methods yet, use the REST API or upgrade after that release is published.

After that SDK release, install the Python example dependencies with pip install agentmail cryptography PyJWT httpx, or the TypeScript dependencies with npm install agentmail jose.

1from __future__ import annotations
2
3import base64
4from datetime import datetime
5from typing import Any, Dict, Literal, Optional, TypedDict, Union
6
7from agentmail import AgentMail
8from cryptography.hazmat.primitives.asymmetric import ec
9
10
11class OrganizationScope(TypedDict):
12 type: Literal["organization"]
13
14
15class PodScope(TypedDict):
16 type: Literal["pod"]
17 id: str
18
19
20class InboxScope(TypedDict):
21 type: Literal["inbox"]
22 id: str
23
24
25Scope = Union[OrganizationScope, PodScope, InboxScope]
26
27
28def b64url_coordinate(value: int) -> str:
29 return base64.urlsafe_b64encode(value.to_bytes(32, "big")).rstrip(b"=").decode()
30
31
32def public_jwk(private_key: ec.EllipticCurvePrivateKey) -> Dict[str, str]:
33 numbers = private_key.public_key().public_numbers()
34 return {
35 "kty": "EC",
36 "crv": "P-256",
37 "x": b64url_coordinate(numbers.x),
38 "y": b64url_coordinate(numbers.y),
39 }
40
41
42def register_agentid_key(
43 client: AgentMail,
44 private_key: ec.EllipticCurvePrivateKey,
45 *,
46 scope: Optional[Scope] = None,
47 name: Optional[str] = None,
48 expires_at: Optional[datetime] = None,
49):
50 request: Dict[str, Any] = {"public_key": public_jwk(private_key)}
51 if scope is not None:
52 request["scope"] = scope
53 if name is not None:
54 request["name"] = name
55 if expires_at is not None:
56 request["expires_at"] = expires_at
57 return client.api_keys.create_public_key(**request)
58
59
60# Generate inside your keystore in production. This in-process object is only a
61# minimal example; persist it before registration so a crash cannot orphan the kid.
62private_key = ec.generate_private_key(ec.SECP256R1())
63client = AgentMail(api_key="YOUR_EXISTING_AGENTMAIL_API_KEY")
64credential = register_agentid_key(
65 client,
66 private_key,
67 name="production signer",
68 scope={"type": "inbox", "id": "agent@example.com"},
69)
70
71# Store this mapping in trusted application state.
72key_record = {
73 "keystore_handle": "opaque-keystore-handle",
74 "kid": str(credential.api_key_id),
75}

The examples keep a process-local private key only to show the types. In a production helper, make the signer accept an opaque keystore handle and return a signature; do not make private key bytes an application-level return value.

Scope and expiry

Omitting scope inherits the registering bearer key’s exact live scope. An explicit scope may be the caller’s scope or a live descendant, never an ancestor or sibling.

Organization scope
1{ "type": "organization" }
Pod scope
1{ "type": "pod", "id": "33333333-3333-4333-8333-333333333333" }
Inbox scope
1{ "type": "inbox", "id": "agent@example.com" }

For expires_at, omission inherits the registering bearer credential’s expiry. If that bearer does not expire, the public-key credential does not expire. An explicit expiry must be in the future and cannot be later than the creator’s expiry. Scope, key material, AgentID eligibility, and expiry are immutable after registration; only name can be patched.

Sign and submit one approval

The protected header and payload are intentionally smaller than a general JWT:

Protected header
1{
2 "alg": "ES256",
3 "typ": "agentid-approval+jwt",
4 "kid": "api_key_id returned by registration"
5}
Signed payload
1{ "jti": "transaction challenge", "inbox_id": "agent@example.com" }

Do not add aud, iat, exp, nonce, scope, or any other claim. Do not add jwk, jku, x5u, x5c, or crit to the protected header. The transaction’s server-side expiry is authoritative. The assertion must be a three-segment compact JWS no larger than 2 KiB; jti is 1–128 characters and inbox_id is 1–254 characters and must identify an email inbox.

1import httpx
2import jwt
3from cryptography.hazmat.primitives.asymmetric import ec
4
5AGENTID_APPROVE_URL = "https://auth.agentid.com/authorize/approve"
6
7
8def approve_agentid_transaction(
9 *,
10 private_key: ec.EllipticCurvePrivateKey,
11 api_key_id: str,
12 jti: str,
13 inbox_id: str,
14) -> None:
15 assertion = jwt.encode(
16 {"jti": jti, "inbox_id": inbox_id},
17 private_key,
18 algorithm="ES256",
19 headers={"alg": "ES256", "typ": "agentid-approval+jwt", "kid": api_key_id},
20 )
21
22 response = httpx.post(
23 AGENTID_APPROVE_URL,
24 json={"assertion": assertion, "inbox_id": inbox_id},
25 # Deliberately no Authorization header and no browser cookies.
26 headers={"Content-Type": "application/json"},
27 timeout=10,
28 )
29 response.raise_for_status()
30 if response.status_code != 204:
31 raise RuntimeError(f"unexpected approval status {response.status_code}")

The unsigned inbox_id in the JSON body is an ergonomic duplicate and must be byte-for-byte equal to the signed claim. The server resolves kid only against a stored public-key credential, verifies the signature, validates the transaction, and rechecks the key, organization, scope, inbox, generation, and expiry before committing one approval. Concurrent or repeated submissions have one winner.

List, rename, revoke, and rotate

The generated clients for this contract expose dedicated lifecycle methods. Legacy api_keys.list, api_keys.create, and api_keys.delete remain bearer-only and have no public-key request member.

1# Public-key list results never include bearer credentials.
2page = client.api_keys.list_public_keys(limit=20)
3
4# Name is the only mutable property.
5renamed = client.api_keys.update_public_key_name(
6 credential.api_key_id,
7 name="production signer 2026-08",
8)
9
10# Rotate by creating the replacement first, deploying its new kid, then deleting old.
11replacement = register_agentid_key(client, replacement_private_key, name="replacement")
12deploy_kid_and_keystore_handle(replacement.api_key_id, replacement_private_key_handle)
13client.api_keys.revoke_public_key(credential.api_key_id)

Registration never updates in place. Even registering identical JWK coordinates again returns a new api_key_id; store and use that new value as kid. Rotation is therefore create new, deploy new, then delete old. Never reuse an old kid for new key material.

For an emergency organization-wide fence, call POST /v0/api-keys/public-keys/agentid-sign-in/revoke-all with an organization-scoped bearer credential and a required UUID Idempotency-Key. The caller normally needs api_key_delete. A verified self-serve agent organization may instead use an unrestricted unmanaged bearer credential for this emergency operation. The request has no body. Repeating the same UUID returns the original {previous_generation, current_generation, revoked_at} receipt and does not advance the generation twice. A new UUID advances it again. Existing rows remain visible with revoked_at for audit; individually revoked keys are deleted.

1import uuid
2
3receipt = client.api_keys.revoke_all_agent_id_sign_in_keys(
4 idempotency_key=str(uuid.uuid4()),
5)
6print(receipt.previous_generation, receipt.current_generation)

Intent and browser-session limitation

A valid signature does not prove who initiated the browser transaction

The approval assertion proves that the key holder approved the server-created transaction identified by jti for one inbox. It does not prove that the key holder initiated the transaction, controls the browser session, inspected the relying party, or intended the relying party’s action.

An attacker can start a valid authorization transaction in the attacker’s own browser, induce an agent to sign that transaction’s jti, and then continue in the same attacker browser session. AgentID’s per-transaction cookie binding prevents a different browser from continuing the flow, but it does not remove this accepted transaction-intent/session-swap residual.

If your product requires intent assurance, bind the displayed relying party and transaction to an authenticated, trusted out-of-band instruction before calling the signing helper. Do not claim that signature validity alone verifies user intent.