Skip to content

Identity assertion

Every provider in OAuth clients starts by asking the MCP server a question: which authorization server do you trust? It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one.

An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. SEP-990, the Enterprise-Managed Authorization extension, moves the decision there. The IdP signs a short-lived JWT, an Identity Assertion JWT Authorization Grant, the ID-JAG: a statement that this user, through this client, may reach this MCP server. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration.

This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from Authorization, checking whatever token shows up.

Two token requests

Two different authorities are in play, and naming them apart is most of understanding this page. The enterprise IdP is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The MCP authorization server is the same party it was in Authorization: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.

The client makes one token request to each.

  1. To the enterprise IdP. The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an RFC 8693 token exchange, it is entirely your IdP's API, and the SDK does not make it. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present.
  2. To the MCP authorization server. The client presents the ID-JAG under the RFC 7523 jwt-bearer grant (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the ID-JAG as assertion) and receives the access token. This is the request the SDK makes, and accepting it is the one thing this page adds to an authorization server.

Everything below is the second request: the client that sends it and the authorization server that answers it.

The client

IdentityAssertionOAuthProvider lives in mcp.client.auth.extensions.identity_assertion. Like every provider in OAuth clients it is an httpx.Auth: construct one, put it on auth=, hand the httpx.AsyncClient to the transport.

client.py
import time
import uuid

import httpx
import jwt

from mcp import Client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken

IDP_SIGNING_KEY = "the-enterprise-idp-signing-key"


class InMemoryTokenStorage:
    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 idp_issue_id_jag(subject: str, audience: str, resource: str) -> str:
    now = int(time.time())
    claims = {
        "iss": "https://idp.example.com",
        "sub": subject,
        "aud": audience,
        "client_id": "finance-agent",
        "resource": resource,
        "scope": "notes:read",
        "jti": str(uuid.uuid4()),
        "iat": now,
        "exp": now + 300,
    }
    return jwt.encode(claims, IDP_SIGNING_KEY, algorithm="HS256", headers={"typ": "oauth-id-jag+jwt"})


async def fetch_id_jag(audience: str, resource: str) -> str:
    return idp_issue_id_jag("alice@example.com", audience, resource)


oauth = IdentityAssertionOAuthProvider(
    server_url="http://localhost:8001/mcp",
    storage=InMemoryTokenStorage(),
    client_id="finance-agent",
    client_secret="finance-agent-secret",
    issuer="https://auth.example.com/",
    assertion_provider=fetch_id_jag,
    scope="notes:read",
)


async def main() -> None:
    async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
        transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
        async with Client(transport) as client:
            result = await client.list_tools()
            print([tool.name for tool in result.tools])

Read it from the bottom.

  • main() is the main() from OAuth clients, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token.
  • The provider takes what the other providers cannot discover: a client_id and client_secret somebody pre-registered with the authorization server, that authorization server's issuer, and assertion_provider, an async callback that returns a fresh ID-JAG on demand.
  • storage is the same TokenStorage protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no client_info to remember.

The assertion provider

fetch_id_jag(audience, resource) is the only code you write. It is awaited once per token exchange, never at construction, and only after the authorization server's metadata has been fetched and validated, so a misconfigured issuer never leaks an assertion. Its two arguments are two of the claims the ID-JAG must be minted with: audience is the authorization server's issuer (the ID-JAG aud) and resource is the MCP server's canonical identifier (the ID-JAG resource). The third is one you already hold: the ID-JAG's client_id claim must name the client_id you gave the provider, or the authorization server refuses the exchange.

idp_issue_id_jag above it is not your code. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real fetch_id_jag makes the first token request of the previous section instead: an RFC 8693 token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that SEP-990 profiles. The signed-in user's ID token goes in as the subject_token, the requested_token_type is the ID-JAG's own URN (urn:ietf:params:oauth:token-type:id-jag), audience and resource pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation.

Tip

A fresh ID-JAG is requested for every exchange, and that is the point: it is a single-use, minutes-lived grant, and the authorization server on this page refuses to accept the same one twice. Do not cache it. The access token it buys you is the thing that gets reused.

The issuer is configuration

Here is the inversion. OAuthClientProvider asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: issuer is required, the RFC 8414 metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything.

The extension does not demand this; it is a deliberately stricter choice. This client carries two things worth stealing, a pre-registered secret and an audience-bound assertion, and a client that let a compromised MCP server steer it to an attacker's authorization server would post both to it. Pinning the issuer at construction deletes that conversation.

Warning

The configured issuer is compared to the metadata document's issuer field by RFC 8414 §3.3 simple string comparison: character for character, trailing slash included, no normalization. Do not guess it. Fetch /.well-known/oauth-authorization-server from your authorization server and copy the issuer value it returns. For the authorization server on this page that is https://auth.example.com/, with the slash, because its issuer was built from a pydantic URL object. A mismatch stops the flow at OAuthFlowError: Authorization server metadata issuer mismatch before a single credential or assertion is sent.

A confidential client

client_secret is required; the constructor raises ValueError without one. The IETF profile underneath SEP-990 reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. token_endpoint_auth_method picks where it travels: client_secret_post (the default, in the form body) or client_secret_basic (an HTTP Basic header). The profile also permits private_key_jwt; this provider does not support it.

Tip

Read client_secret from the environment or a secret manager, never from source control.

What the provider does for you

The first request goes out unauthenticated, and the server's 401 starts the flow.

  1. Discovery. It fetches the authorization server metadata from the configured issuer's RFC 8414 well-known path, checks the document's issuer matches, and checks the token endpoint is on the issuer's origin.
  2. The assertion. It awaits your assertion_provider.
  3. Exchange. It POSTs the jwt-bearer grant to the token endpoint, stores the OAuthToken, and replays your original request with Authorization: Bearer ....

A 403 whose WWW-Authenticate names insufficient_scope runs steps 2 and 3 again with the union of your scope and the challenged one. (scope is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next 401 mints a fresh ID-JAG and exchanges again, and that is the lever the IdP holds. Failures are the same two exceptions as the rest of OAuth clients: OAuthFlowError for discovery and validation, its subclass OAuthTokenError when the token endpoint says no.

The authorization server

Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of SEP-990 is the client above.

The SDK can also be the authorization server: create_auth_routes returns the authorization server's routes as a list any Starlette app can mount, which is how examples/servers/simple-auth/ in the repository runs one. SEP-990 adds one flag and one method to that surface:

auth_server.py
import secrets
import time

import jwt
from pydantic import AnyHttpUrl
from starlette.applications import Starlette

from mcp.server.auth.provider import (
    AccessToken,
    AuthorizationCode,
    AuthorizationParams,
    AuthorizeError,
    IdentityAssertionParams,
    OAuthAuthorizationServerProvider,
    RefreshToken,
    TokenError,
)
from mcp.server.auth.routes import create_auth_routes
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken

ISSUER = "https://auth.example.com/"
MCP_SERVER = "http://localhost:8001/mcp"
IDP_ISSUER = "https://idp.example.com"
IDP_SIGNING_KEY = "the-enterprise-idp-signing-key"

REGISTERED_CLIENTS = {
    "finance-agent": OAuthClientInformationFull(
        client_id="finance-agent",
        client_secret="finance-agent-secret",
        redirect_uris=None,
        grant_types=[JWT_BEARER_GRANT_TYPE],
        token_endpoint_auth_method="client_secret_post",
    )
}


class EnterpriseAuthorizationServer(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
    def __init__(self) -> None:
        self.access_tokens: dict[str, AccessToken] = {}
        self.seen_jtis: set[str] = set()

    async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
        return REGISTERED_CLIENTS.get(client_id)

    async def load_access_token(self, token: str) -> AccessToken | None:
        return self.access_tokens.get(token)

    async def exchange_identity_assertion(
        self, client: OAuthClientInformationFull, params: IdentityAssertionParams
    ) -> OAuthToken:
        try:
            header = jwt.get_unverified_header(params.assertion)
            claims = jwt.decode(
                params.assertion,
                IDP_SIGNING_KEY,
                algorithms=["HS256"],
                issuer=IDP_ISSUER,
                audience=ISSUER,
                options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]},
            )
        except jwt.InvalidTokenError as error:
            raise TokenError("invalid_grant", "the assertion did not verify") from error
        if header.get("typ") != "oauth-id-jag+jwt":
            raise TokenError("invalid_grant", "the assertion is not an ID-JAG")
        if claims["client_id"] != client.client_id:
            raise TokenError("invalid_grant", "the assertion was issued to a different client")
        if claims["resource"] != MCP_SERVER:
            raise TokenError("invalid_target", "the assertion is for a resource this server does not serve")
        if claims["jti"] in self.seen_jtis:
            raise TokenError("invalid_grant", "the assertion has already been used")
        self.seen_jtis.add(claims["jti"])
        scopes = claims["scope"].split()
        access_token = f"mcp_{secrets.token_hex(16)}"
        self.access_tokens[access_token] = AccessToken(
            token=access_token,
            client_id=claims["client_id"],
            scopes=scopes,
            expires_at=int(time.time()) + 300,
            resource=claims["resource"],
            subject=claims["sub"],
        )
        return OAuthToken(access_token=access_token, token_type="Bearer", expires_in=300, scope=" ".join(scopes))

    async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
        raise AuthorizeError("unauthorized_client", "this authorization server only accepts ID-JAGs")

    async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> None:
        return None

    async def exchange_authorization_code(
        self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
    ) -> OAuthToken:
        raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs")

    async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> None:
        return None

    async def exchange_refresh_token(
        self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
    ) -> OAuthToken:
        raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs")


provider = EnterpriseAuthorizationServer()
auth_app = Starlette(
    routes=create_auth_routes(provider, issuer_url=AnyHttpUrl(ISSUER), identity_assertion_enabled=True)
)
  • identity_assertion_enabled=True gates everything. Off, which is the default, /token answers this grant with unsupported_grant_type even if you implemented the hook, and the metadata does not mention it. On, the metadata gains the jwt-bearer grant type and lists urn:ietf:params:oauth:grant-profile:id-jag in authorization_grant_profiles_supported, the field the extension uses to advertise support. (This SDK's client never reads it: it is provisioned for one issuer and simply asks.)
  • exchange_identity_assertion is the hook. Before it runs, the SDK has authenticated the client, refused public clients, and refused clients whose registration does not list the grant. You get an IdentityAssertionParams (the raw assertion, the requested scopes and resource) and return a plain OAuthToken.
  • Dynamic client registration refuses this grant unconditionally, so get_client here serves a hand-provisioned client. An ID-JAG client cannot register itself into existence.
  • Half the class is refusals. OAuthAuthorizationServerProvider is the whole authorization server, so it also asks for the authorization-code flow; a server that signs users in as well implements those for real, and this one has exactly one door.

Warning

The SDK never decodes the assertion: only your deployment knows which IdP it trusts and which keys that IdP publishes, so everything inside exchange_identity_assertion is load-bearing. Verify the signature against the IdP's published keys (its JWKS; the shared secret here is the demo's), and iss and exp, per RFC 7523 §3. Require the JWT header's typ to be oauth-id-jag+jwt, the profile's guard against some other JWT being replayed as a grant. Require aud to be your own issuer. Require the ID-JAG's client_id claim to equal the client the handler authenticated, and its resource claim to name a resource you actually serve. Track jti until the assertion's exp so it is accepted once. And take the granted scopes and, above all, the issued token's resource from the validated ID-JAG, never from the request: params.resource is whatever the client typed. The full processing rules are in the Enterprise-Managed Authorization specification.

Reject a bad assertion with TokenError("invalid_grant", ...). The other error code in this flow is invalid_target: an ID-JAG that names a resource you do not serve is refused with it, which is what stops this server minting tokens for somebody else's. And the granted scopes come from the ID-JAG's scope claim (an assertion without one is refused too); yours might map the user's groups instead.

And notice what the returned OAuthToken does not carry: a refresh token. The IdP decides how long this user keeps access by deciding whether to issue the next ID-JAG. A refresh token minted here would quietly hand that decision back.

Info

A server that still embeds its authorization server with auth_server_provider= reaches the same code through AuthSettings(identity_assertion_enabled=True). Authorization explains why new servers should not start there.

Check

Wire the two files on this page together and the whole grant is one POST /token:

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
client_id=finance-agent
resource=http://localhost:8001/mcp
scope=notes:read
client_secret=finance-agent-secret

HTTP/1.1 200 OK
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}

No /authorize, no /register, no protected-resource-metadata fetch. The only requests on the wire are the one that drew the 401, the well-known fetch, this exchange, and then ordinary MCP traffic with the bearer attached. And the sub your validator read out of the ID-JAG is exactly what get_access_token().subject reports inside a tool.

Try it

examples/stories/identity_assertion/ in the SDK repository is this page running for real: the same exchange_identity_assertion validator, an MCP server gated on its tokens, a stand-in IdP, and the client, in one self-checking program. uv run python -m stories.identity_assertion.client --http runs the whole exchange and asserts that the user the IdP named is the user the tool sees.

Recap

  • SEP-990 lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an ID-JAG.
  • Obtaining the ID-JAG is an RFC 8693 token exchange against your IdP, and the SDK does not make it. Presenting it to the MCP authorization server is the RFC 7523 jwt-bearer grant, and the SDK does both sides of that.
  • IdentityAssertionOAuthProvider is another httpx.Auth: a pre-registered confidential client, a pinned issuer, and one assertion_provider(audience, resource) callback. No browser, no registration, no refresh token.
  • The authorization server is never discovered from the resource server. Configure issuer to exactly the string its metadata document serves; the comparison is character for character.
  • Server side, identity_assertion_enabled=True plus exchange_identity_assertion. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's resource, not the request's.

The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in Authorization.