---
title: "OAuth 2.1 for Agents"
description: "Client Credentials grant, PKCE, token caching, and JWT validation for M2M auth"
url: "https://agentsurface.dev/docs/authentication/oauth-for-agents"
lastVerified: 2026-09-24
lastModified: 2026-09-25T05:46:01.000Z
---



## Summary [#summary]

The Client Credentials grant is the baseline for service-owned agent credentials. Agents POST client\_id and client\_secret to the token endpoint, receive a short-lived JWT, cache and reuse it for its full lifetime. JWT validation checks signature, issuer, audience, and expiry before trusting any claim inside.

* Client Credentials grant: single POST for runtime credentials, no redirect or session state
* Short-lived tokens (1–4 hours) cached and refreshed proactively
* Scopes follow principle of least privilege (resource:action pairs)
* JWT validation: always verify signature, issuer, audience, expiry
* User-delegated access: Authorization Code with PKCE; Token Exchange where narrower downstream delegation is needed

Agents need OAuth 2.1, but service-owned agents should not depend on a browser-based Authorization Code flow for routine API access. The baseline grant is **Client Credentials** ([RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)): the agent makes a single POST to the token endpoint with credentials, receives a short-lived JWT, caches it, and uses it on every request. No redirect, no session cookie, no login page in the request path.

For user-delegated access, use Authorization Code with PKCE: the person grants access through the client-supported consent flow, then protected requests use access tokens. This is the core MCP authorization case; Client Credentials is an optional extension for service identities. A bounded registration flow such as [`auth.md`](/docs/authentication/auth-md) serves a separate registration need and does not replace MCP OAuth. See the [MCP authorization overview](https://modelcontextprotocol.io/extensions/auth/overview).

## Client Credentials Request [#client-credentials-request]

POST to the token endpoint with `client_id`, `client_secret`, and the scopes your agent needs:

```bash
curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=agent_01HV3K8MNP" \
  -d "client_secret=cs_live_..." \
  -d "scope=invoices:read customers:read"
```

Response includes the access token, type, expiry in seconds, and the granted scopes:

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "invoices:read customers:read"
}
```

No `refresh_token`. When the token expires, request a new one using the same credentials. The credentials themselves are the persistent authorization.

## Token Caching and Lifecycle [#token-caching-and-lifecycle]

Short-lived tokens (1–4 hours) are cheap to issue and validate. Cache them and reuse for their lifetime. Refresh proactively before expiry with a buffer (typically 5 minutes).

```typescript
import { jwtDecode } from "jose";

interface TokenCache {
  accessToken: string;
  expiresAt: number; // Unix timestamp
}

let cache: TokenCache | null = null;

async function getAccessToken(config: {
  clientId: string;
  clientSecret: string;
  tokenUrl: string;
  scopes: string[];
}): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  const bufferSeconds = 5 * 60;

  // Return cached token if valid (with 5-minute buffer)
  if (cache && cache.expiresAt - now > bufferSeconds) {
    return cache.accessToken;
  }

  const form = new URLSearchParams({
    grant_type: "client_credentials",
    client_id: config.clientId,
    client_secret: config.clientSecret,
    scope: config.scopes.join(" "),
  });

  const res = await fetch(config.tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: form.toString(),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Token request failed: ${err.error} — ${err.error_description}`);
  }

  const { access_token, expires_in } = await res.json();

  cache = {
    accessToken: access_token,
    expiresAt: now + expires_in,
  };

  return access_token;
}
```

Use the cached token on all requests:

```typescript
const token = await getAccessToken(config);
const res = await fetch("https://api.example.com/invoices", {
  headers: { Authorization: `Bearer ${token}` },
});
```

Calling the token endpoint per request multiplies auth load by request volume. Cache aggressively.

## Scopes: The Principle of Least Privilege [#scopes-the-principle-of-least-privilege]

Each token should carry only the scopes required for that specific task. A reporting agent that reads invoices does not need `invoices:write` or `admin`. An agent that lists users does not need `users:write` or `payments:*`.

Design scopes as `resource:action` pairs:

```
invoices:read            read and list invoices
invoices:write           create and update invoices
customers:read           read and list customers
customers:write          create and update customers
reports:generate         export reports
admin                    everything (rarely needed)
```

Request the minimum for each operation:

```typescript
// Reading invoices: only invoices:read
const readToken = await getAccessToken({
  ...config,
  scopes: ["invoices:read"],
});

// Creating invoices: only invoices:write
const writeToken = await getAccessToken({
  ...config,
  scopes: ["invoices:write"],
});
```

For multi-step workflows where scope requirements vary, use Token Exchange (RFC 8693) to derive narrow, time-limited tokens per step.

## JWT Validation [#jwt-validation]

Tokens from OAuth servers are JWTs. Validate before trusting any claim inside. Use `jose`:

```typescript
import { jwtVerify, createRemoteJWKSet } from "jose";

const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));

async function validateToken(token: string): Promise<Record<string, any>> {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: "https://auth.example.com/",
    audience: "https://api.example.com/",
  });

  // payload.iss, payload.aud, payload.exp, payload.scope are now trusted
  return payload;
}
```

Validation checks, in order:

1. **Signature:** Fetch the issuer's JWKS and verify the JWT signature. Never skip.
2. **Issuer (`iss`):** Must match your auth server's URI.
3. **Audience (`aud`):** Must include your service's identifier.
4. **Expiry (`exp`):** Must be a Unix timestamp in the future (with small clock skew allowance).

If your tokens include `org_id` or `tenant_id`, validate those as part of authorization - not authentication:

```typescript
async function authorize(token: string, resourceOrgId: string): Promise<void> {
  const payload = await validateToken(token);

  const scopes = ((payload.scope as string) || "").split(" ");
  if (!scopes.includes("invoices:read")) {
    throw new Error("Missing scope: invoices:read");
  }

  if (payload.org_id !== resourceOrgId) {
    throw new Error("Token org does not match resource");
  }
}
```

## Authorization Code + PKCE [#authorization-code--pkce]

Authorization Code with PKCE is the standard choice for user-delegated MCP access. Consent happens during connection setup; subsequent protected calls use tokens with bounded lifetime, audience and scopes. PKCE binds the authorization request to the code exchange; it is not an extra step in the Client Credentials grant.

```http
GET /authorize?response_type=code
  &client_id=client_123
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
  &scope=openid%20profile%20invoices%3Aread
  &code_challenge=...
  &code_challenge_method=S256

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=...
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=client_123
&code_verifier=...
```

PKCE ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)) is mandatory in OAuth 2.1 even for confidential clients. It prevents authorization-code interception attacks.

## Checklist [#checklist]

* [ ] Client Credentials is used for service-owned M2M auth
* [ ] Tokens are cached for their full lifetime
* [ ] Token cache refreshes proactively (5+ minutes before expiry)
* [ ] Each request asks for only the minimum required scopes
* [ ] JWT validation checks signature, `iss`, `aud`, and `exp`
* [ ] Custom claims like `org_id` are validated as authorization, not authentication
* [ ] Token Exchange (RFC 8693) is used when narrowing scope for downstream services
* [ ] Bearer tokens are in `Authorization` headers, never in query strings

## See Also [#see-also]

* [Token Exchange](/docs/authentication/token-exchange) - narrowing scope for sub-agents
* [API Keys](/docs/authentication/api-keys) - simpler alternative for single-service credentials
* [Agent Identity](/docs/authentication/agent-identity) - first-class agent principals and audit trails
* [DPoP](/docs/authentication/dpop) - sender-constrained tokens
* [auth.md](/docs/authentication/auth-md) - registering or claiming user-bound credentials
* [OAuth 2.1 draft](https://oauth.net/2.1/) - current OAuth 2.1 work
* [RFC 6749: OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) - Client Credentials and token endpoints
