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 and there is no "on behalf of." The agent is the thing being authorized, and right now it's authorized by a string in an environment variable.
What the API key actually is
An API key is a long-lived bearer secret. Which means:
- It is the identity, not proof of one. Whoever holds the bytes is the agent.
- It has to be copied to be used. Into a deploy config, a container, 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 log records that the key was used, not which of your eleven agents used it.
Now hand one to an agent. Agents read untrusted input, write to logs, and may run in a runtime you don't own. Every property on that list gets worse.
The interesting failure isn't a hacker stealing a key. It's an agent 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 is the client credentials grant: the agent authenticates as itself and gets an access token. That part is old. What matters for agents is how it authenticates and what kind of token it gets back.
1. Authenticate with a key, not a secret
The agent signs a short-lived assertion with a private key. The server holds only the public key, from the
agent's jwks_uri, so nothing secret is transmitted or stored to be breached. This is
private_key_jwt (RFC 7523); mutual TLS
(RFC 8705) does the same at the transport layer.
That still leaves a private key on disk, the weakest thing on this page. Where your platform can do better it should, and the way is posture assessment: before anything issues a credential, something evaluates what build this is, where it's running, and what a TPM or enclave measures. Those signals decide whether a credential is issued at all, and how long it lasts.
Two drafts carry that into the token endpoint. Attestation-based client authentication lets the environment vouch for the agent instance. 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
The agent re-authenticates whenever it needs a new one. That 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 it matches the key 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 server puts that in the audience. A token for the ticketing API is not a token for the payments API. The API key never made that distinction.
The flow
Four requests, start to finish
-
Agent→Ticketing API
The agent knows the API's URL and fetches
/.well-known/oauth-protected-resourceto find which authorization server protects it. (Here 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 doing this properly now costs a library call.