OAuth·for·Agents

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 real risk

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 authenticating as itself
  1. Agent→Ticketing API

    The agent knows the API's URL and fetches /.well-known/oauth-protected-resource to find which authorization server protects it. (Here you often just configure this; from scenario 02 onward, discovery does real work.)

  2. Agent→Authorization Server

    It fetches /.well-known/oauth-authorization-server to find the token endpoint and which authentication methods are supported.

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

  4. Authorization Server→Agent

    Verifies the assertion against the agent's published public key, and returns a DPoP-bound access token good for five minutes.

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

PropertyAPI keyThis
Lifetime of a compromiseUntil someone notices and rotatesMinutes, and only with the private key
Where the secret livesEvery config that uses it, plus the vendor's databaseOne key, never transmitted; ideally issued by the platform and never on disk
Scope of a leakEverything the key is valid forOne API, one scope, one key holder
Who the log namesThe keyThe agent, by client ID, with its key thumbprint
RotationA projectPublish a new key at the jwks_uri
Worth noticing

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.