---
title: "Machine-to-Machine (M2M) MCP Authentication with the Client Credentials Grant"
description: "How a headless agent authenticates to an MCP server with the client credentials grant: the draft extension, the SDK providers, and the header that works everywhere."
canonicalUrl: "https://zuplo.com/learn/mcp/authentication/client-credentials"
pageType: "mcp-auth-guide"
method: "client-credentials"
lastVerified: "2026-07-29"
specVersion: "2026-07-28"
---

# Machine-to-Machine (M2M) MCP Authentication with the Client Credentials Grant

> When there is no user to send to a consent screen, the agent authenticates as itself: it asks your identity provider for a token, then presents that token to the MCP server.

**Client credentials (M2M) · Any platform**

## The exchange

The agent authenticates as itself at the identity provider's token endpoint using a client secret or a signed JWT assertion, receives a short-lived access token, and presents it as a bearer token on the MCP request. The MCP server verifies the signature against the identity provider's JWKS, checks the audience and scope, and reads the calling service from the subject claim. No browser and no user appear anywhere in the flow.

**Participants:** Your agent (cron job, CI step), Identity provider (token endpoint), MCP server (verifies via JWKS)

1. **Your agent → Identity provider** — `POST /oauth2/token` — `grant_type=client_credentials` _(carries the credential)_
2. **Identity provider → Your agent** — `access_token` — `aud = https://mcp.example.com/mcp` _(response; carries the credential)_
3. **Your agent → MCP server** — `POST /mcp` — `Authorization: Bearer <access-token>` _(carries the credential)_
4. **MCP server** (internal step) — Verify the signature against the IdP's JWKS
5. **MCP server** (internal step) — Check aud and scope, then read the caller — `sub = my-service`
6. **MCP server → Your agent** — `200 OK` — `tool result` _(response)_

Notes:

- **Steps 1–2** — No browser and no user. The credential is the client's own — a secret, or a JWT signed with its private key, which the extension recommends instead. This is the step the spec's interactive flow has no equivalent for, and the reason that flow fails in CI.
- **Steps 4–5** — Ordinary JWT validation: signature, audience, expiry, scope. A server that already accepts OAuth tokens from users needs no new code path for these, only a decision about which subjects are services.

## How it works

The agent posts `grant_type=client_credentials` — or a JWT assertion signed with its own private key — to your identity provider's token endpoint, gets an access token back, and sends it as `Authorization: Bearer <token>` on every MCP request. There is an official MCP extension for this, `io.modelcontextprotocol/oauth-client-credentials`, and the TypeScript and Python SDKs ship providers that do the token fetch and refresh for you. It is still Draft, and the extension support matrix lists no client that implements it: ChatGPT and Claude both document that they do not support machine-to-machine grants. So the extension is for agents you write — and the degenerate version, fetch the token yourself and set the header, works in every SDK that takes headers.

_MCP Specification 2026-07-28._

### At a glance

- **Best for** — Cron jobs, CI steps and background workers, where no user is present to consent
- **MCP spec** — An official MCP extension, still Draft — shipped in the TypeScript and Python SDKs
- **Works with** — Your own agents — the extension support matrix lists no client that implements it
- **Effort** — An afternoon, if your IdP already issues client-credentials tokens

## Connect your agent

Each sample connects to the server, sends the credential, and exposes the tools to the model.

### TypeScript

#### MCP TypeScript SDK

```typescript title="agent.ts"
import {
  Client,
  ClientCredentialsProvider,
  StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";

const provider = new ClientCredentialsProvider({
  clientId: "my-service",
  clientSecret: process.env.MCP_CLIENT_SECRET!,
});

const client = new Client(
  { name: "my-service", version: "1.0.0" },
  { capabilities: {} },
);

// `authProvider` is what triggers the token request — there is no
// header to set. Swap in PrivateKeyJwtProvider for RFC 7523.
const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.example.com/mcp"),
  { authProvider: provider },
);

await client.connect(transport);
const { tools } = await client.listTools();
```

#### Claude Agent SDK

```typescript title="agent.ts"
import { query } from "@anthropic-ai/claude-agent-sdk";

// This SDK takes headers and nothing else, so fetch the token
// yourself. That is the whole of M2M in a headers-only SDK.
const { access_token } = await fetch(
  "https://idp.example.com/oauth2/token",
  {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: "my-service",
      client_secret: process.env.MCP_CLIENT_SECRET!,
      scope: "mcp:tools",
      // RFC 8707. Send it; IdPs that ignore it will ignore it.
      resource: "https://mcp.example.com/mcp",
    }),
  },
).then((r) => r.json());

for await (const message of query({
  prompt: "Search for open orders.",
  options: {
    mcpServers: {
      example: {
        type: "http",
        url: "https://mcp.example.com/mcp",
        headers: { Authorization: `Bearer ${access_token}` },
      },
    },
    // Anything unlisted stops for a permission prompt, which a
    // headless run never gets past.
    allowedTools: ["mcp__example__search"],
  },
})) {
  console.log(message);
}
```

### Python

#### MCP Python SDK

```python title="agent.py"
import asyncio, os, httpx2
from mcp import Client
from mcp.client.auth.extensions.client_credentials import (
    ClientCredentialsOAuthProvider,
)
from mcp.client.streamable_http import streamable_http_client

# `storage` has no default: write a class with get_tokens /
# set_tokens / get_client_info / set_client_info. Persist it, or you
# pay a token request per process.
provider = ClientCredentialsOAuthProvider(
    server_url="https://mcp.example.com/mcp",
    storage=InMemoryTokenStorage(),
    client_id="my-service",
    client_secret=os.environ["MCP_CLIENT_SECRET"],
    scopes="mcp:tools",
)

async def main():
    # The provider is an auth flow for httpx2 — not httpx.
    async with httpx2.AsyncClient(auth=provider) as http_client:
        transport = streamable_http_client(
            "https://mcp.example.com/mcp", http_client=http_client)
        async with Client(transport) as client:
            print(await client.list_tools())

asyncio.run(main())
```

#### OpenAI Agents SDK

```python title="agent.py"
import asyncio, os, httpx
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp

async def main():
    # Nothing in this SDK performs a grant. Two-legged OAuth here is
    # one form post plus one header.
    async with httpx.AsyncClient() as http:
        r = await http.post("https://idp.example.com/oauth2/token", data={
            "grant_type": "client_credentials",
            "client_id": "my-service",
            "client_secret": os.environ["MCP_CLIENT_SECRET"],
            "resource": "https://mcp.example.com/mcp",
        })
        token = r.json()["access_token"]

    async with MCPServerStreamableHttp(
        name="Example MCP",
        params={
            "url": "https://mcp.example.com/mcp",
            "headers": {"Authorization": f"Bearer {token}"},
        },
    ) as server:
        agent = Agent(name="Assistant", mcp_servers=[server])
        print((await Runner.run(agent, "List your tools.")).final_output)

asyncio.run(main())
```

### Go

#### MCP Go SDK

```go title="main.go"
import (
    "context"
    "net/url"
    "os"

    "github.com/modelcontextprotocol/go-sdk/mcp"
    "golang.org/x/oauth2/clientcredentials"
)

// Config.Client returns an *http.Client that fetches the token and
// re-fetches it on expiry, which is exactly what the transport wants.
func connect(ctx context.Context) (*mcp.ClientSession, error) {
    cfg := &clientcredentials.Config{
        ClientID:     "my-service",
        ClientSecret: os.Getenv("MCP_CLIENT_SECRET"),
        TokenURL:     "https://idp.example.com/oauth2/token",
        Scopes:       []string{"mcp:tools"},
        // EndpointParams is the only place to put RFC 8707 resource.
        EndpointParams: url.Values{
            "resource": {"https://mcp.example.com/mcp"},
        },
    }

    client := mcp.NewClient(
        &mcp.Implementation{Name: "my-agent", Version: "1.0.0"}, nil)

    return client.Connect(ctx, &mcp.StreamableClientTransport{
        Endpoint:   "https://mcp.example.com/mcp",
        HTTPClient: cfg.Client(ctx),
    }, nil)
}
```

## When to use something else

**Use this when:**

- A scheduled job, a CI step or a background worker makes the call, and there is nobody to send to a consent screen.
- Your identity provider already issues client-credentials tokens, so the MCP server validates them with the JWKS work it is already doing.
- You want the credential to expire on its own: the extension notes these tokens are typically shorter-lived than user-delegated ones, where an API key lasts until somebody rotates it.

**Use something else when:**

- The server has to be reachable from Claude or ChatGPT: both document that they do not support machine-to-machine grants.
- The call is made on behalf of a named person and the API behind the server enforces per-user permissions.
- The agent and the MCP server run in the same cloud, where workload identity issues the token and there is no secret at all.

> The core specification makes authorization `OPTIONAL` and describes only the interactive authorization-code flow. Where it acknowledges a client acting on its own behalf, it is to excuse it: in the step-up rules, `client_credentials` clients MAY "abort the request immediately" rather than re-authorize. That sentence is identical in `2025-11-25` and in `2026-07-28`, the current protocol version. Machine-to-machine auth sits outside both, in the `io.modelcontextprotocol/oauth-client-credentials` extension, still in the `draft` directory of the ext-auth repository.

## What lands on you next

A client-credentials token proves which service is calling. It says nothing about which tools that service may call, how often, or what reaches the API behind your server.

- **The token carries `scope: mcp:tools`. Is anything checking that?** — `jwt-scopes-inbound` — Only if something reads it. This policy requires the token's space-delimited `scope` claim to contain every scope you list, and refuses the call at the route before your server runs.
- **Sales wants these tools in Claude. Claude will not do client credentials.** — `mcp-oauth-inbound` — The inbound method is a policy on the route rather than code in your server, so an interactive sign-in against the identity provider you already run is configuration. Exactly one such policy per project.
- **The scheduler retried all night against one token. Who stops it?** — `rate-limit-inbound` — With `rateLimitBy: "user"` every call is counted against the authenticated subject rather than a shared egress IP, so one looping service account cannot spend everyone's budget.
- **Can we forward this token to the API behind the MCP server?** — `mcp-token-exchange-inbound` — The spec forbids passthrough with a MUST NOT. Inbound auth headers are stripped and an independent upstream credential is used, so the token you present is never the token that leaves.

All of these attach to one MCP route's `policies.inbound` — the same policy engine on the way in and on the way out. See [Zuplo MCP Gateway](/mcp-gateway).

## FAQ

**Does MCP support machine-to-machine authentication?**

Yes, through an official extension rather than the core specification: io.modelcontextprotocol/oauth-client-credentials. It is still Draft — it lives in the draft directory of the ext-auth repository — and the extension support matrix on modelcontextprotocol.io currently lists no client that implements it. The official TypeScript and Python SDKs do.

**Why does my MCP server connect from my desktop but fail in CI?**

Because the spec's flow is interactive. It opens a browser, a human signs in, and a human approves the consent screen. In a scheduled job there is no browser and nobody to click, so the flow hangs or fails at the redirect. That is the gap client credentials exists to close.

**Do I need the extension to do M2M today?**

No, and mostly you should not wait for it. Request a token from your identity provider's token endpoint yourself and set Authorization: Bearer on the MCP client. Every agent SDK that accepts headers supports that today, and the server sees an ordinary bearer token either way.

**What is the difference between the client credentials flow and the authorization code flow?**

Who the token represents. Authorization code produces a token for a user who signed in and consented, which needs a browser. Client credentials produces a token for the calling application itself, from a single request to the token endpoint. The two are sometimes called three-legged and two-legged OAuth.

**Should I use a client secret or private_key_jwt?**

The extension recommends JWT bearer assertions, defined in RFC 7523: the client signs a short-lived assertion with its private key and the authorization server validates it against the registered public key. A client secret is a long-lived credential that authenticates as your application until it is rotated, so the extension's guidance is to prefer assertions where you can.

**Is a client-credentials token the same thing as a service account?**

In practice, usually yes — service account, machine identity, workload credential and M2M client are the same idea under different vendor names. What differs is where the key material lives. A cloud workload identity has no stored secret at all: the platform mints a token from the workload's ambient identity, so there is nothing to rotate or leak.

**Can ChatGPT or Claude connect to a server that requires this?**

No. OpenAI's plugin authentication documentation states that ChatGPT does not support machine-to-machine OAuth grants such as client credentials, service accounts, or JWT bearer assertions. Anthropic's connector documentation is as direct: a pure machine-to-machine client_credentials grant is not supported, and every connection requires user consent.

**What changed for M2M in the 2026-07-28 spec release?**

Nothing about this mechanism: client credentials was an extension before the release and is an extension after it, still Draft. What did change around it is that Dynamic Client Registration is now deprecated in favour of Client ID Metadata Documents, and the initialize handshake and protocol-level sessions are gone — so a headless client that used DCR to self-register has one more reason to stop.

## Keep reading

- **OAuth 2.1** — [OAuth 2.1 Authentication for MCP Servers](/learn/mcp/authentication/oauth-2-1) — Servers that must be reachable by MCP clients you do not control, or listed in a connector directory
- **API key** — [API Key Authentication for MCP Servers](/learn/mcp/authentication/api-key) — Service-to-service calls where you control both the agent and the MCP server
- **MCP Authentication** — [every authentication method](/learn/mcp/authentication)
