Agent Surface

Authentication

OAuth 2.1 for Agents

Client Credentials grant, PKCE, token caching, and JWT validation for M2M auth

Last verified 2026-09-24

Summary

The Client Credentials grant is the baseline for service-owned agent credentials. Agents POST client_id and client_secret to the 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; 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 access. The baseline grant is Client Credentials (RFC 6749 §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 authorization case; Client Credentials is an optional extension for service identities. A bounded registration flow such as auth.md serves a separate registration need and does not replace MCP OAuth. See the MCP authorization overview.

Client Credentials Request

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

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:

{
  "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

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

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:

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

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:

// 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

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

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 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:

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

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) is mandatory in OAuth 2.1 even for confidential clients. It prevents authorization-code interception attacks.

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

On this page