Agent Identity Built and Measured

📘 Tutorials 2026-11-04 6 min read

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

CredentialSizeSignVerifyInstant revokeAttribution
Static API key46 charspre-issued0.1 µs (dict hit)Yes, delete itOnly to "which key"
HS256 JWT355 chars17.3 µs27.7 µsNo, needs a revocation tableDown to sub
Ed25519 JWT398 chars56.9 µs156.4 µsNo, sameDown 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

ClaimMeasured valueJob
isshttps://id.example.comWho signed it; verify against one issuer only
subagent:refund-botWhich agent, never a human username
audbilling-apiOne service only; useless elsewhere
scopeorders:read orders:refundWhat it may do
act{"sub":"user:10492"}On whose behalf
jti16 random bytesRevocation and replay handle
exp / iat300 seconds apartValidity 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

CaseExceptionMessage
ExpiredExpiredSignatureErrorSignature has expired
nbf 30s in the futureImmatureSignatureErrorThe token is not yet valid (nbf)
Wrong audienceInvalidAudienceErrorAudience doesn't match
alg=none forgeryInvalidAlgorithmErrorThe specified alg value is not allowed
HS256 signed with the public PEMInvalidKeyErrorThe specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
Payload tampered (scope widened)InvalidSignatureErrorSignature 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 rowMeasured default
agent_id (sub)agent:<name>, separate from human accounts
Public key / kidEd25519, 113-byte PEM, rotate multiple
Allowed scopesSplit by service, three or fewer per agent
On behalf of (act)User id, chained on each hop
TTL60 to 300 seconds
jtiUnique 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.

Related Articles
2026-08-01
Embedding API Price Comparison 2026: OpenAI vs Cohere vs Gemini vs Mistral
2026-08-01
RAG Agent with LangGraph 2026: Build a Research Agent That Uses Tools
2026-07-19
AI Agent Market Overview: How to Choose Between Coze, GPTs, AgentGPT and Other Platforms
2026-08-01
Multi-Agent Systems Examples in 2026: 8 Real-World Deployments
2026-07-26
Self-Hosted AI + n8n Workflow Automation
2026-07-16
AI Agent Knowledge Base Setup 2026

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.

💬 Comments (0)

No comments yet. Be the first!

Login to comment