Skip to content
AgentMail
AgentMail
Advanced

AgentID public-key authentication

Approve AgentID sign-in with a scoped P-256 credential while the private key stays in your own keystore, HSM, or KMS.

If you don’t have an API key yet, follow the Quickstart first.

An AgentID public-key credential lets an agent prove possession of a P-256 private key when approving a sign-in, using only a compact signature. Neither the bearer API key nor the private key is ever transmitted.

Generate and use the private key in a keystore, HSM, KMS, or a small trusted signing process: the model gets an opaque signing capability, never the key material: not as a JWK or PEM, not in an environment variable, and not in tool output, logs, prompts, or conversation history.

Register a public key

  1. Generate a P-256 key pair in trusted code. Persist the private key in secure storage. Export only the public components: { "kty": "EC", "crv": "P-256", "x": ..., "y": ... }.

  2. Register the public JWK, authenticated with an existing AgentMail bearer API key:

curl -X POST "https://api.agentmail.to/v0/api-keys/public-keys" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "public_key": { "kty": "EC", "crv": "P-256", "x": "<base64url>", "y": "<base64url>" },
    "name": "production signer 2026-08"
  }'

The server verifies the key and computes its RFC 7638 SHA-256 fingerprint. The response’s api_key_id is the value you use as the JWS kid when signing approvals.

  1. Store the mapping {keystore_handle, kid} in trusted application state before registration completes, so a crash cannot orphan the credential.

Scope and expiry

Omit scope to inherit the registering bearer’s scope. An explicit scope must be the caller’s own scope or a live descendant of it, never an ancestor or sibling:

ScopeFormat
Organization{ "type": "organization" }
Pod{ "type": "pod", "id": "<pod_id>" }
Inbox{ "type": "inbox", "id": "agent@example.com" }

Omitting expires_at inherits the bearer’s expiry; if the bearer never expires, neither does the credential. An explicit expiry must be in the future and cannot exceed the creator’s expiry.

Scope, key material, AgentID eligibility, and expiry are immutable after registration. Only name can be patched.

Sign and submit an approval

For one pending authorization transaction, sign exactly the claims jti and inbox_id with ES256:

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

Do not add aud, iat, exp, nonce, scope, or any other claims, and do not include jwk, jku, x5u, x5c, or crit in the header. The assertion must be a three-segment compact JWS of at most 2 KiB. jti is 1 to 128 characters and inbox_id is 1 to 254 characters.

The signing itself is standard ES256: jose in TypeScript, or PyJWT with cryptography in Python, plus httpx to submit.

Submit the assertion to the AgentID issuer. This endpoint is served by the issuer, not the AgentMail REST API or the generated SDKs, so call it directly:

curl -X POST "https://auth.agentid.com/authorize/approve" \
  -H "Content-Type: application/json" \
  -d '{
    "assertion": "<compact.jws.string>",
    "inbox_id": "agent@example.com"
  }'

Send no bearer authorization and no browser cookies. The unsigned inbox_id in the JSON body must be byte-for-byte identical to the signed claim. Success is 204 No Content. The server resolves kid against the stored credential, verifies the signature, validates the transaction, and rechecks key, organization, scope, inbox, generation, and expiry. Concurrent or repeated submissions have one winner.

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

Manage credentials

List public-key credentials. Results never include bearer credentials:

curl "https://api.agentmail.to/v0/api-keys/public-keys?limit=20" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY"

Rename one. name is the only mutable property:

curl -X PATCH "https://api.agentmail.to/v0/api-keys/public-keys/<api_key_id>" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "production signer 2026-08" }'

Rotate

Rotation is create new, deploy new, then delete old:

  1. Register the replacement public key with the same call as above. Even identical JWK coordinates return a new api_key_id.
  2. Deploy the new kid and its keystore handle to your signer.
  3. Revoke the old credential. Never reuse an old kid for new key material:
curl -X DELETE "https://api.agentmail.to/v0/api-keys/public-keys/<api_key_id>" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY"

Emergency revoke-all

One call idempotently invalidates every current AgentID sign-in key in the organization. It requires an organization-scoped bearer credential and a UUID Idempotency-Key header, and takes no request body:

curl -X POST "https://api.agentmail.to/v0/api-keys/public-keys/agentid-sign-in/revoke-all" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
  -H "Idempotency-Key: <uuid>"

The response returns previous_generation, current_generation, and revoked_at. Repeating the call with the same UUID returns the original receipt; a different UUID advances the generation again. Individually revoked keys are deleted, while rows revoked by revoke-all remain visible with revoked_at for audit.

Next Steps

Was this page helpful?Suggest editsRaise issue