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:
- Signature: Fetch the issuer's and verify the JWT signature. Never skip.
- Issuer (
iss): Must match your auth server's URI. - Audience (
aud): Must include your service's identifier. - 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, andexp - Custom claims like
org_idare validated as authorization, not authentication - Token Exchange (RFC 8693) is used when narrowing scope for downstream services
- Bearer tokens are in
Authorizationheaders, never in query strings
See Also
- Token Exchange - narrowing scope for sub-agents
- API Keys - simpler alternative for single-service credentials
- Agent Identity - first-class agent principals and audit trails
- DPoP - sender-constrained tokens
- auth.md - registering or claiming user-bound credentials
- OAuth 2.1 draft - current OAuth 2.1 work
- RFC 6749: OAuth 2.0 Authorization Framework - Client Credentials and token endpoints