Scenario 00 · no user involved
The agent acts as itself
Every morning at 6am, an agent reads yesterday's support tickets and writes a summary. Nobody is logged in. There is no "on behalf of." The agent is the thing being authorized — and right now, it is being authorized by a string in an environment variable.
What the API key actually is
It's worth being blunt about the thing we're replacing, because it's the most widely deployed authorization mechanism in software and almost nobody defends it on the merits.
An API key is a long-lived bearer secret. Which means:
- It is the identity. Not proof of an identity — the identity itself. Whoever holds the bytes is the agent.
- It has to be copied to be used. Into a deploy config, a secrets manager, a container, a developer's laptop for debugging, a CI log by accident.
- It has no audience. If it leaks, it works from anywhere, against everything it was ever valid for.
- It has no expiry. Rotation is a manual project that gets scheduled and then doesn't happen.
- It says nothing about the caller. The audit log records that the key was used. It doesn't record which of your eleven agents used it, or from where.
Handing one of these to an agent — software that reads untrusted input, writes to logs, and may run inside a runtime you don't own — makes every one of those properties worse.
The interesting failure isn't a hacker stealing a key. It's an agent being asked to "debug the connection issue," dutifully printing its own environment, and putting a permanent production credential into a transcript that gets pasted into a ticket.
The replacement
An identity the agent proves, not a secret it holds
The OAuth shape for this is the client credentials grant — the agent authenticates as itself and gets an access token. That part is old. The part that matters for agents is how it authenticates and what kind of token it gets back.
1. Authenticate with a key, not a secret
Instead of sending a shared secret, the agent signs a short-lived assertion with a private key and sends
the signature. The authorization server holds only the public key, fetched from the agent's
jwks_uri. Nothing secret is ever transmitted, and nothing secret is stored on the server side
to be breached. This is private_key_jwt, standardized in
RFC 7523. Mutual TLS
(RFC 8705) achieves the same thing at the transport layer.
That still leaves a private key on disk, which is the weakest thing on this page. Where your platform can do better, it should — and the way it does better is posture assessment: before anything issues the agent a credential, something evaluates signals about it. What image is this. Where is it running. What does the orchestrator say about it. Where the hardware allows, what do a TPM or an enclave measure. Those signals decide whether a credential is issued at all, what it says, and how long it lasts — and then it's issued at runtime, short-lived, and rotated, rather than being a file somebody put there once.
Two drafts carry that into the token endpoint. Attestation-based client authentication lets the environment vouch for the agent instance, and SPIFFE client authentication lets a workload authenticate with the identity its infrastructure already gave it. Both mean the agent holds no long-lived credential of its own.
2. Get a token that expires in minutes
Access tokens are short-lived by design. The agent re-authenticates whenever it needs a new one, which costs it a round trip and costs an attacker their entire window.
3. Bind the token to the key
DPoP makes the access token useless on its own. Every request carries a fresh proof signed by the agent's key, and the resource checks that the proof's key matches the one the token was issued against. Copy the token out of a log and it does nothing.
4. Bind the token to one API
Resource indicators let the agent say which API the token is for, and the authorization server puts that in the audience. A token for the ticketing API is not a token for the payments API — a distinction the API key never made.
The flow
Four requests, start to finish
-
Agent→Ticketing API
The agent knows the API's URL. It fetches
/.well-known/oauth-protected-resourceto find out which authorization server protects it. (In scenario 00 you often just configure this; from scenario 02 onward, discovery does real work.) -
Agent→Authorization Server
It fetches
/.well-known/oauth-authorization-serverto find the token endpoint and which authentication methods are supported. -
Agent→Authorization Server
It signs a client assertion with its private key and a DPoP proof with the same key, and asks for a token scoped to the ticketing API.
-
Authorization Server→Agent
Verifies the assertion against the agent's published public key, and returns a DPoP-bound access token good for five minutes.
-
Agent→Ticketing API
Calls the API with the token plus a fresh DPoP proof over this exact method and URL. The API verifies both, and logs the agent's client ID as the caller.
On the wire
The requests
Discovery
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: auth.example.com
→ 200 OK
{
"issuer": "https://auth.example.com",
"token_endpoint": "https://auth.example.com/token",
"token_endpoint_auth_methods_supported": ["private_key_jwt"],
"dpop_signing_alg_values_supported": ["ES256"],
"grant_types_supported": ["client_credentials", "authorization_code"]
}
The client assertion the agent signs
Signed with the agent's private key; the server verifies it against the public key at the agent's jwks_uri.
{
"iss": "ticket-summarizer",
"sub": "ticket-summarizer",
"aud": "https://auth.example.com",
"jti": "f7c1a0e2-9b3d-4a11-8c42-0d5b7e6a91cf",
"iat": 1787894520,
"exp": 1787894580
}
The DPoP proof
A separate JWT, one per request, carrying the agent's public key in the header.
// header
{ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }
// payload
{
"jti": "6a0d4e1c-77b2-4f8e-b3a5-2c9e1f0d8b47",
"htm": "POST",
"htu": "https://auth.example.com/token",
"iat": 1787894520
}
The token request
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
grant_type=client_credentials
&resource=https%3A%2F%2Ftickets.example.com%2F
&scope=tickets.read
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3A
client-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIsImtpZCI6ImFnZW50LTEifQ...
→ 200 OK
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "DPoP",
"expires_in": 300,
"scope": "tickets.read"
}
Calling the API
GET /v1/tickets?since=2026-08-25 HTTP/1.1
Host: tickets.example.com
Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
The proof on this request has htm: "GET", htu set to this URL, and an
ath claim containing a hash of the access token. A proof captured from one request cannot be
replayed against another.
What the access token says
{
"iss": "https://auth.example.com",
"sub": "ticket-summarizer", // the agent is the subject — no user here
"aud": "https://tickets.example.com/", // useless anywhere else
"client_id": "ticket-summarizer",
"scope": "tickets.read",
"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }, // bound to the key
"exp": 1787894820
}
Access token format is RFC 9068; the
cnf/jkt binding is DPoP.
What you traded up for
| Property | API key | This |
|---|---|---|
| Lifetime of a compromise | Until someone notices and rotates | Minutes, and only with the private key |
| Where the secret lives | Every config that uses it, plus the vendor's database | One key, never transmitted; ideally issued by the platform and never on disk |
| Scope of a leak | Everything the key is valid for | One API, one scope, one key holder |
| Who the log names | The key | The agent, by client ID, with its key thumbprint |
| Rotation | A project | Publish a new key at the jwks_uri |
Nothing on this rung is new. Client credentials shipped in 2012; DPoP in 2023. What's new is the reason to care: an agent is a much leakier place to keep a secret than a backend job, and the cost of doing this properly has dropped to a library call.