# Enterprise-managed authorization and client credentials for MCP

> Source: <https://pournasserian.com/writing/mcp-2026-enterprise-auth>
> Published: 2026-10-24 00:00:00+00:00

# Enterprise-managed authorization and client credentials for MCP

Core Model Context Protocol (MCP) authorization pictures one person in a browser, approving one client for one server. That fits less well when a company’s information technology (IT) team wants to decide centrally who may use which server, or when the caller is a scheduled job with nobody there to click anything. Two official extensions cover those cases.

This is part 28 of my series on what an MCP server does under the 2026-07-28 specification, after [part 27 on Skills over MCP](https://pournasserian.com/writing/mcp-2026-skills). It builds on [part 18](https://pournasserian.com/writing/mcp-2026-oauth-resource-server) for core authorization and [part 23](https://pournasserian.com/writing/mcp-2026-extensions) for how extensions work. The facts are as I read them in October 2026.

## In brief

1. **Two official extensions cover what core authorization handles poorly.** Enterprise-Managed Authorization (stable) puts MCP access under the company’s identity provider (IdP); OAuth Client Credentials (draft) serves callers with no user present.
2. **In the enterprise flow, the user never sees your authorization server’s login page.** The client trades a corporate sign-in for an Identity Assertion JSON Web Token (JWT) Authorization Grant (ID-JAG) at the IdP, then the ID-JAG for an access token.
3. **For client credentials, prefer a signed JWT assertion to a client secret.** The private key never leaves the service.
4. **Support is early.** As of October 2026, the mainstream chat and integrated development environment (IDE) hosts list neither extension.

## Which authorization model fits

Both extensions live in the [`ext-auth` repository](https://github.com/modelcontextprotocol/ext-auth):

| Extension | Identifier | Status, October 2026 | Solves | 
|---|---|---|---|
| [Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) | `io.modelcontextprotocol/enterprise-managed-authorization` | Stable | IT controls MCP access centrally through the corporate IdP, with no per-user, per-server consent | 
| [OAuth Client Credentials](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials) | `io.modelcontextprotocol/oauth-client-credentials` | Draft | Machine-to-machine access with no user present | 

Both are optional and additive: they don’t change core behaviour, they compose with each other, and they’re versioned independently.

Which one you need depends on who is calling. Core authorization uses a Proof Key for Code Exchange (PKCE), and a local server on standard input and output (stdio), as in [part 7](https://pournasserian.com/writing/mcp-2026-transports), takes its credentials from the environment:

## Enterprise-managed authorization

Letting each user authorize each server is right for consumers. In a company it causes friction and gaps: employees shouldn’t have to understand every server’s authorization details, security teams can’t enforce consistent policy, and onboarding and offboarding mean authorizing, then revoking, dozens of services by hand.

The extension makes the organization’s IdP (Okta, Microsoft Entra ID or a corporate single sign-on (SSO) system) the authoritative decision-maker. Users sign in to the MCP client with corporate SSO. The client gets an ID-JAG from the IdP and exchanges it at your MCP server’s authorization server, which validates it against the IdP’s JSON Web Key Set (JWKS) and issues an access token. The user is never redirected to that authorization server’s login page.

What the organization gets:

- **Central policy.** The IdP holds the approved MCP servers and each one’s access rules.
- **One sign-in.** No per-server consent prompts.
- **Enforcement before issuance.** Users without access never receive a token.
- **Revocation in one place.** Removing access at the IdP covers every client.
- **An audit trail** for compliance.

### What each party does

| Party | Duties | 
|---|---|
| MCP client | Declare the extension on each request. Support SSO with the enterprise IdP and keep the identity assertion, an OpenID Connect (OIDC) ID token or a Security Assertion Markup Language (SAML) assertion. Exchange it for an ID-JAG, then redeem that at the MCP authorization server. Support IdP configuration at the organization level. Handle scope errors gracefully | 
| MCP server | Declare the extension in its authorization metadata to require the enterprise flow. Optionally publish a resource descriptor, so admins can configure policy in the IdP console | 
| MCP authorization server | Validate ID-JAGs: signature against the IdP’s JWKS, issuer, audience, expiry. Map claims to permissions. Link accounts using `sub` as the stable key, falling back to email for accounts created before enterprise management | 

### In Python

In the official Python software development kit (SDK), `mcp` 2.3.0, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) covers the last exchange, presenting the ID-JAG with the JWT-bearer grant of Request for Comments (RFC) 7523. Getting the ID-JAG is left to you, through an `assertion_provider` callback. It serves confidential clients only, so it needs a client secret. It also takes your authorization server’s `issuer` as configuration, so the MCP server can’t redirect the ID-JAG.

## OAuth client credentials

Background services, scheduled jobs, continuous integration and delivery (CI/CD) pipelines, server-to-server integrations and daemons have no human present and can’t open a browser. This extension adds the OAuth 2.0 client credentials grant, so they authenticate with the application’s own credentials instead of a user’s. If a human should explicitly authorize access, use the standard flow.

It allows two credential formats:

|  | JWT bearer assertion, RFC 7523 | Client secret | 
|---|---|---|
| Secret on the wire | No, the private key stays local | Yes | 
| Lifetime | Short, minutes | Long-lived until rotated | 
| Risk if leaked | A limited window | Silent impersonation until rotation | 
| Guidance | Preferred | Store in a secrets manager, rotate regularly, scope minimally | 

- **Clients** declare the extension on each request, obtain a token before connecting, send it as a bearer token, and refresh it before expiry. These tokens often live shorter than user tokens.
- **Servers** validate the JWT’s signature and claims against the authorization server’s JWKS, check scopes per operation and, as a recommendation, advertise the extension in[`server/discover`](https://pournasserian.com/writing/mcp-2026-server-discover) .

### In Python

`mcp` 2.3.0 has a provider for each format, in `mcp.client.auth.extensions.client_credentials`: `ClientCredentialsOAuthProvider` for a client ID and secret, and `PrivateKeyJWTOAuthProvider` for a signed assertion (the TypeScript SDK’s are `ClientCredentialsProvider` and `PrivateKeyJwtProvider`). Each plugs into `httpx2`, the SDK’s Hypertext Transfer Protocol (HTTP) client. The assertion comes from your `assertion_provider` callback: in production, the SDK says, usually from a workload identity provider. For testing and simple setups, `SignedJWTParameters` signs it with your private key, as here:

``` python
import httpx2

from mcp.client import Client, advertise
from mcp.client.auth.extensions.client_credentials import (
    PrivateKeyJWTOAuthProvider,
    SignedJWTParameters,
)
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken

MCP_URL = "https://mcp.example.com/mcp"
CLIENT_ID = "report-service"  # registered with the authorization server, with its public key

class MemoryTokenStorage:
    """The SDK's TokenStorage protocol, kept in memory."""

    def __init__(self) -> None:
        self.tokens: OAuthToken | None = None
        self.client_info: OAuthClientInformationFull | None = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.client_info

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.client_info = client_info

def make_provider(private_key_pem: str) -> PrivateKeyJWTOAuthProvider:
    """The private key signs assertions here and never leaves this service."""
    assertions = SignedJWTParameters(issuer=CLIENT_ID, subject=CLIENT_ID, signing_key=private_key_pem)
    return PrivateKeyJWTOAuthProvider(
        server_url=MCP_URL,
        storage=MemoryTokenStorage(),
        client_id=CLIENT_ID,
        assertion_provider=assertions.create_assertion_provider(),
        issuer="https://auth.example.com",  # the only server an assertion is sent to
    )

async def nightly_report(http: httpx2.AsyncClient) -> str:
    """Runs with no user present; the provider gets the token."""
    declared = advertise("io.modelcontextprotocol/oauth-client-credentials")
    async with Client(streamable_http_client(MCP_URL, http_client=http), extensions=[declared]) as client:
        result = await client.call_tool("list_files", {})
        return result.content[0].text
```

I ran it with `httpx2.AsyncClient(auth=make_provider(key))`, in one process, against the SDK’s own MCP server, a stand-in authorization server and a throwaway key pair. The requests:

``` php
POST mcp.example.com/mcp -> 401
GET mcp.example.com/.well-known/oauth-protected-resource/mcp -> 200
GET auth.example.com/.well-known/oauth-authorization-server -> 200
POST auth.example.com/token -> 200
POST mcp.example.com/mcp -> 200
POST mcp.example.com/mcp -> 200
POST mcp.example.com/mcp -> 200
```

What the token endpoint received:

```
grant_type: client_credentials
client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer
resource: https://mcp.example.com/mcp
scope: files:read
client_secret sent: False
assertion claims: {'iss': 'report-service', 'sub': 'report-service', 'aud': 'https://auth.example.com'} lifetime: 300 s
```

What the run showed:

- **No secret on the wire.** The JWT travels as a client assertion beside`grant_type=client_credentials` and lives 300 seconds, the`SignedJWTParameters` default. The scope came from the server’s Protected Resource Metadata.
- **One line declares the extension.**`advertise(...)` put it in the client capabilities of every request’s`_meta` .
- **`issuer=` pins where credentials go.** Without it, 2.3.0 warns that 3.0 will require it.
- **Tokens come after a 401.** The first token came only after a 401 and a discovery of both metadata documents. When the two-second demo token expired, the next request went the same way. That isn’t yet what the extension asks of clients: a token before connecting, and a refresh before expiry.

## Support, and my advice for Entra ID

The [official client matrix](https://modelcontextprotocol.io/extensions/client-matrix) lists Enterprise-Managed Authorization in Archestra.AI and in Apify’s mcpc command-line client, and Client Credentials in mcpc. As of October 2026, the mainstream chat and IDE hosts don’t list either. Enterprise-Managed Authorization also needs support on the IdP’s side, so check with your IdP vendor.

What follows is my advice, not part of either extension. If your MCP servers sit behind Entra ID in a large organization, keep Enterprise-Managed Authorization on your roadmap. It removes the per-user consent friction that Entra’s lack of dynamic registration otherwise forces you to work around ([part 19](https://pournasserian.com/writing/mcp-2026-oauth-step-up-and-entra) covers what to expect from Entra today). Until hosts support it, pre-registered clients with core authorization, and Client Credentials for automation, are the practical options.

## Method and caveats

- Built from my guide’s chapter on authorization extensions, against the 2026-07-28 specification; the status and support facts are as of October 2026.
- The Python sample ran against `mcp` 2.3.0 on 9 October 2026, in one process with no network; the output is what it printed.
- Where my notes were less precise about the Python SDK (which classes it ships, and how its providers renew a token), this article follows the installed package.
