Agent Identity Built and Measured
Five agents shared one API key for three months. A disputed refund arrived with nothing in the log but the same key repeated, and rotating it took all five agents down at once.
💡 What You Will Learn
Five agents shared one API key for three months. A disputed refund arrived with nothing in the log but the same key repeated, and rotating it took all five agents down at once.
📜 Table of Contents
A support agent ran on one API key from a .env file for three months. When a refund was questioned later, every request in the log carried the same key: no way to tell which agent sent it, or on whose behalf. Rotating the key stopped five agents at once. The system recognized a key, not the thing holding it.
Agent identity sounds abstract until you have to build it. Three decisions do almost all the work: what the credential is, what goes inside it, and how fast revocation takes effect. All three are measurable. Numbers below come from runs on Python 3.11.6 with PyJWT 2.13.0 and cryptography 49.0.0: 2,000 signing iterations, 10,000 verification iterations, HTTP leg measured over 200 loopback requests.
Three credential forms, two orders of magnitude apart
| Credential | Size | Sign | Verify | Instant revoke | Attribution |
|---|---|---|---|---|---|
| Static API key | 46 chars | pre-issued | 0.1 µs (dict hit) | Yes, delete it | Only to "which key" |
| HS256 JWT | 355 chars | 17.3 µs | 27.7 µs | No, needs a revocation table | Down to sub |
| Ed25519 JWT | 398 chars | 56.9 µs | 156.4 µs | No, same | Down to sub + act |
Per core that is roughly 36,000 HS256 verifications per second against 6,400 for Ed25519, a 5.6x gap. On a real request the gap nearly disappears: loopback median 0.67 ms with an API key and 1.02 ms with Ed25519, p95 0.82 against 1.49 ms, a 0.35 ms difference. The header grows from 57 to 408 bytes, irrelevant inside a 1.5 KB request.
The point is not to pick the cheaper one. At 50 rps, Ed25519 verification uses 0.8% of one core, and asymmetric keys mean the server stores only public keys: a leak narrows from "every downstream" to "one signing service". That trade fits agents, whose tokens get copied into logs, traces and prompts.
What belongs in the token
| Claim | Measured value | Job |
|---|---|---|
| iss | https://id.example.com | Who signed it; verify against one issuer only |
| sub | agent:refund-bot | Which agent, never a human username |
| aud | billing-api | One service only; useless elsewhere |
| scope | orders:read orders:refund | What it may do |
| act | {"sub":"user:10492"} | On whose behalf |
| jti | 16 random bytes | Revocation and replay handle |
| exp / iat | 300 seconds apart | Validity window |
The Ed25519 signature segment is a fixed 86 base64url characters. A single-key JWKS document measured 146 bytes and parsed in 0.7 ms, so cache it instead of fetching per request.
Six failure modes, verbatim
| Case | Exception | Message |
|---|---|---|
| Expired | ExpiredSignatureError | Signature has expired |
| nbf 30s in the future | ImmatureSignatureError | The token is not yet valid (nbf) |
| Wrong audience | InvalidAudienceError | Audience doesn't match |
| alg=none forgery | InvalidAlgorithmError | The specified alg value is not allowed |
| HS256 signed with the public PEM | InvalidKeyError | The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret. |
| Payload tampered (scope widened) | InvalidSignatureError | Signature verification failed |
PyJWT rejects both classic attacks on its own because the algorithm allowlist is enforced. What breaks in production is hand-rolled verification that widens algorithms, or calls jwt.decode without an expected audience.
HTTP layer: 401 for missing_credentials, invalid_api_key, token_expired and wrong_audience; 403 for jti_revoked, and 403 with {"error":"scope_missing","need":"orders:refund","held":"orders:read","amount":180} when a read-only token hits the refund route. Same route with orders:refund returns {"ok":true,"refunded":180}. Scope checks live in code, never in a prompt.
Revocation: you cannot un-sign, only shorten the window
A signed token is a stamped letter. Revoking it means a table lookup per request: 0.07 µs for an in-memory set, 1.7 µs for a SQLite primary-key hit, against 156 µs of signature verification. The check is effectively free. The window is not: a 300-second token at 50 rps can be signed into 15,000 requests before it expires, and revocation may lag up to 300 seconds. Drop the TTL to 60 seconds and the window shrinks fivefold while refresh frequency rises fivefold. Sixty to three hundred seconds plus a revocation table is the working range.
Delegation costs 43 bytes per hop
A planner agent calling a worker: root token 379 characters with act of {"sub":"user:10492"}; child token 422 characters with act of {"sub":"agent:planner","chain":["user:10492"]}. At depths 1, 2 and 4 the tokens measured 443, 471 and 527 characters, about 28 characters per hop. Negligible.
What matters is that the issuer trims the child scope. Asking for orders:read admin:* from a parent holding orders:read orders:refund threw PermissionError: scope escalation: ['admin:*'] not held by parent. Enforced at issuance, not left to the sub-agent's good behavior.
DPoP costs 253 µs per request
Bearer tokens work for whoever holds them, and agent tokens leak into logs. A DPoP proof binds the token to a private key: the JWK thumbprint is 43 characters, the proof 417 characters, full verification 252.7 µs across 3,000 iterations. Replay defenses held: the same proof passed on first submission and threw ValueError: replay: jti seen on the second, htm mismatch: GET when the method changed, htu mismatch against another host. The jti cache grows one entry per request, so it needs expiry or it leaks memory.
Two mistakes I made building this
First, my initial harness returned 200 for a token I had marked revoked. The revocation set and the live-session table were the same dictionary, so "present" read as "valid". A deny list has to be separate, and checked first. In production that bug is exactly a blocklist sharing a table with the user directory.
Second, an expired token without a kid header came back as {"error":"unknown_kid","kid":null} instead of token_expired, because the keyset check ran before signature verification, so the expiry check never executed. Clients then chase key rotation for a token that simply expired. Order: verify signature, check exp and aud, then consult revocation.
Checklist
| Per agent, store one row | Measured default |
|---|---|
| agent_id (sub) | agent:<name>, separate from human accounts |
| Public key / kid | Ed25519, 113-byte PEM, rotate multiple |
| Allowed scopes | Split by service, three or fewer per agent |
| On behalf of (act) | User id, chained on each hop |
| TTL | 60 to 300 seconds |
| jti | Unique per request, into revocation and replay caches |
The standards are settled: RFC 9068 for JWT access tokens, RFC 8693 token exchange for downscoped tokens and the act claim, RFC 8707 resource indicators, RFC 9449 DPoP. The MCP 2025-06-18 authorization spec composes them: the server is an OAuth resource server, must validate audience, returns 401 for invalid tokens and 403 for insufficient scope, and must not pass a received token through to a downstream service.
Written by our editorial team; tools listed here are tested or verified against public sources. Links point to official sites or GitHub repos for reference only — no paid placements.