diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx
index 836b5a4aea..a5b316d881 100644
--- a/docs/integrations/azure.mdx
+++ b/docs/integrations/azure.mdx
@@ -282,3 +282,47 @@ Parameters (`jwt_signing_key` and `client_storage`) work together to ensure toke
For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters).
+
+## Token Verification Only (Managed Identity)
+
+
+
+For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`.
+
+This pattern is ideal when:
+- Your infrastructure handles authentication (e.g., Managed Identity)
+- You don't need the OAuth proxy flow (no `client_secret` required)
+- You just need to verify that incoming Azure AD tokens are valid
+
+```python server.py
+from fastmcp import FastMCP
+from fastmcp.server.auth import RemoteAuthProvider
+from fastmcp.server.auth.providers.azure import AzureJWTVerifier
+from pydantic import AnyHttpUrl
+
+tenant_id = "your-tenant-id"
+client_id = "your-client-id"
+
+# AzureJWTVerifier auto-configures JWKS, issuer, and audience
+verifier = AzureJWTVerifier(
+ client_id=client_id,
+ tenant_id=tenant_id,
+ required_scopes=["access_as_user"], # Scope names from Azure Portal
+)
+
+auth = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[
+ AnyHttpUrl(f"https://login.microsoftonline.com/{tenant_id}/v2.0")
+ ],
+ base_url="https://your-container-app.azurecontainerapps.io",
+)
+
+mcp = FastMCP(name="Azure MI App", auth=auth)
+```
+
+`AzureJWTVerifier` handles Azure's scope format automatically. You write scope names exactly as they appear in Azure Portal under **Expose an API** (e.g., `access_as_user`). The verifier validates tokens using the short-form scopes that Azure puts in the `scp` claim, while advertising the full URI scopes (e.g., `api://your-client-id/access_as_user`) in OAuth metadata so MCP clients know what to request.
+
+
+For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`.
+
diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx
index 5b894a7ea0..7c94a937fa 100644
--- a/docs/servers/auth/remote-oauth.mdx
+++ b/docs/servers/auth/remote-oauth.mdx
@@ -127,6 +127,21 @@ This configuration creates a server that accepts tokens issued by `auth.yourcomp
The `authorization_servers` list tells MCP clients which identity providers you trust. The `base_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `base_url` should point to your server base URL - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use `https://api.yourcompany.com` as the base URL.
+### Overriding Advertised Scopes
+
+Some identity providers use different scope formats for authorization requests versus token claims. For example, Azure AD requires clients to request full URI scopes like `api://client-id/read`, but the token's `scp` claim contains just `read`. The `scopes_supported` parameter lets you advertise the full-form scopes in metadata while validating against the short form:
+
+```python
+auth = RemoteAuthProvider(
+ token_verifier=token_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://api.example.com",
+ scopes_supported=["api://my-api/read", "api://my-api/write"],
+)
+```
+
+When not set, `scopes_supported` defaults to the token verifier's `required_scopes`. For Azure AD specifically, see the [AzureJWTVerifier](/integrations/azure#token-verification-only-managed-identity) which handles this automatically.
+
### Custom Endpoints
You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires.
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index 95f1ad3a01..9a804f05d7 100644
--- a/src/fastmcp/server/auth/auth.py
+++ b/src/fastmcp/server/auth/auth.py
@@ -274,6 +274,17 @@ def __init__(
"""
super().__init__(base_url=base_url, required_scopes=required_scopes)
+ @property
+ def scopes_supported(self) -> list[str]:
+ """Scopes to advertise in OAuth metadata.
+
+ Defaults to required_scopes. Override in subclasses when the
+ advertised scopes differ from the validation scopes (e.g., Azure AD
+ where tokens contain short-form scopes but clients request full URI
+ scopes).
+ """
+ return self.required_scopes or []
+
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a bearer token and return access info if valid."""
raise NotImplementedError("Subclasses must implement verify_token")
@@ -299,6 +310,7 @@ def __init__(
token_verifier: TokenVerifier,
authorization_servers: list[AnyHttpUrl],
base_url: AnyHttpUrl | str,
+ scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
):
@@ -308,6 +320,10 @@ def __init__(
token_verifier: TokenVerifier instance for token validation
authorization_servers: List of authorization servers that issue valid tokens
base_url: The base URL of this server
+ scopes_supported: Scopes to advertise in OAuth metadata. If None,
+ uses the token verifier's scopes_supported property. Use this
+ when the scopes clients request differ from the scopes that
+ appear in tokens (e.g., Azure AD full URI scopes vs short-form).
resource_name: Optional name for the protected resource
resource_documentation: Optional documentation URL for the protected resource
"""
@@ -317,6 +333,7 @@ def __init__(
)
self.token_verifier = token_verifier
self.authorization_servers = authorization_servers
+ self._scopes_supported = scopes_supported
self.resource_name = resource_name
self.resource_documentation = resource_documentation
@@ -343,7 +360,11 @@ def get_routes(
create_protected_resource_routes(
resource_url=resource_url,
authorization_servers=self.authorization_servers,
- scopes_supported=self.token_verifier.required_scopes,
+ scopes_supported=(
+ self._scopes_supported
+ if self._scopes_supported is not None
+ else self.token_verifier.scopes_supported
+ ),
resource_name=self.resource_name,
resource_documentation=self.resource_documentation,
)
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index 36fb8975a1..cc0d2544e9 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -452,3 +452,103 @@ async def _extract_upstream_claims(
except Exception as e:
logger.debug("Failed to extract Azure claims: %s", e)
return None
+
+
+class AzureJWTVerifier(JWTVerifier):
+ """JWT verifier pre-configured for Azure AD / Microsoft Entra ID.
+
+ Auto-configures JWKS URI, issuer, audience, and scope handling from your
+ Azure app registration details. Designed for Managed Identity and other
+ token-verification-only scenarios where AzureProvider's full OAuth proxy
+ isn't needed.
+
+ Handles Azure's scope format automatically:
+ - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims)
+ - Advertises full-URI scopes in OAuth metadata (what clients need to request)
+
+ Example::
+
+ from fastmcp.server.auth import RemoteAuthProvider
+ from fastmcp.server.auth.providers.azure import AzureJWTVerifier
+ from pydantic import AnyHttpUrl
+
+ verifier = AzureJWTVerifier(
+ client_id="your-client-id",
+ tenant_id="your-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+
+ auth = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[
+ AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0")
+ ],
+ base_url="https://my-server.com",
+ )
+ """
+
+ def __init__(
+ self,
+ *,
+ client_id: str,
+ tenant_id: str,
+ required_scopes: list[str] | None = None,
+ identifier_uri: str | None = None,
+ base_authority: str = "login.microsoftonline.com",
+ ):
+ """Initialize Azure JWT verifier.
+
+ Args:
+ client_id: Azure application (client) ID from your App registration
+ tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers").
+ For multi-tenant apps ("organizations" or "consumers"), issuer validation
+ is skipped since Azure tokens carry the actual tenant GUID as issuer.
+ required_scopes: Scope names as they appear in Azure Portal under "Expose an API"
+ (e.g., ["access_as_user", "read"]). These are validated against
+ the short-form scopes in token ``scp`` claims, and automatically
+ prefixed with identifier_uri for OAuth metadata.
+ identifier_uri: Application ID URI (defaults to ``api://{client_id}``).
+ Used to prefix scopes in OAuth metadata so clients know the full
+ scope URIs to request from Azure.
+ base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
+ For Azure Government, use "login.microsoftonline.us".
+ """
+ self._identifier_uri = identifier_uri or f"api://{client_id}"
+
+ # For multi-tenant apps, Azure tokens carry the actual tenant GUID as
+ # issuer, not the literal "organizations" or "consumers" string. Skip
+ # issuer validation for these — audience still protects against wrong-app tokens.
+ multi_tenant_values = {"organizations", "consumers", "common"}
+ issuer: str | None = (
+ None
+ if tenant_id in multi_tenant_values
+ else f"https://{base_authority}/{tenant_id}/v2.0"
+ )
+
+ super().__init__(
+ jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys",
+ issuer=issuer,
+ audience=client_id,
+ algorithm="RS256",
+ required_scopes=required_scopes,
+ )
+
+ @property
+ def scopes_supported(self) -> list[str]:
+ """Return scopes with Azure URI prefix for OAuth metadata.
+
+ Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp``
+ claim, but clients must request full URI scopes (e.g.,
+ ``api://client-id/read``) from the Azure authorization endpoint. This
+ property returns the full-URI form for OAuth metadata while
+ ``required_scopes`` retains the short form for token validation.
+ """
+ if not self.required_scopes:
+ return []
+ prefixed = []
+ for scope in self.required_scopes:
+ if scope in OIDC_SCOPES or "://" in scope or "/" in scope:
+ prefixed.append(scope)
+ else:
+ prefixed.append(f"{self._identifier_uri}/{scope}")
+ return prefixed
diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py
index 165d3229b5..6bf25a50df 100644
--- a/tests/server/auth/providers/test_azure.py
+++ b/tests/server/auth/providers/test_azure.py
@@ -6,8 +6,12 @@
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
-from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider
-from fastmcp.server.auth.providers.jwt import JWTVerifier
+from fastmcp.server.auth.providers.azure import (
+ OIDC_SCOPES,
+ AzureJWTVerifier,
+ AzureProvider,
+)
+from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
class TestAzureProvider:
@@ -983,3 +987,138 @@ def test_extra_token_params_does_not_contain_scope(self):
["read", "write"]
)
assert len(refresh_scopes) > 0
+
+
+class TestAzureJWTVerifier:
+ """Tests for AzureJWTVerifier pre-configured JWT verifier."""
+
+ def test_auto_configures_from_client_and_tenant(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+ assert (
+ verifier.jwks_uri
+ == "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0"
+ assert verifier.audience == "my-client-id"
+ assert verifier.algorithm == "RS256"
+ assert verifier.required_scopes == ["access_as_user"]
+
+ async def test_validates_short_form_scopes(self):
+ key_pair = RSAKeyPair.generate()
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["access_as_user"],
+ )
+ # Override to use our test key instead of JWKS
+ verifier.public_key = key_pair.public_key
+ verifier.jwks_uri = None
+
+ token = key_pair.create_token(
+ subject="test-user",
+ issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
+ audience="my-client-id",
+ additional_claims={"scp": "access_as_user"},
+ )
+ result = await verifier.load_access_token(token)
+ assert result is not None
+ assert "access_as_user" in result.scopes
+
+ def test_scopes_supported_returns_prefixed_form(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read", "write"],
+ )
+ assert verifier.scopes_supported == [
+ "api://my-client-id/read",
+ "api://my-client-id/write",
+ ]
+
+ def test_already_prefixed_scopes_pass_through(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["api://my-client-id/read"],
+ )
+ assert verifier.scopes_supported == ["api://my-client-id/read"]
+
+ def test_oidc_scopes_not_prefixed(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["openid", "read"],
+ )
+ assert verifier.scopes_supported == ["openid", "api://my-client-id/read"]
+
+ def test_custom_identifier_uri(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ identifier_uri="api://custom-uri",
+ )
+ assert verifier.scopes_supported == ["api://custom-uri/read"]
+
+ def test_custom_base_authority_for_gov_cloud(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ base_authority="login.microsoftonline.us",
+ )
+ assert (
+ verifier.jwks_uri
+ == "https://login.microsoftonline.us/my-tenant-id/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.us/my-tenant-id/v2.0"
+
+ def test_scopes_supported_empty_when_no_required_scopes(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="my-tenant-id",
+ )
+ assert verifier.scopes_supported == []
+
+ def test_default_identifier_uri_uses_client_id(self):
+ verifier = AzureJWTVerifier(
+ client_id="abc-123",
+ tenant_id="my-tenant-id",
+ required_scopes=["read"],
+ )
+ assert verifier.scopes_supported == ["api://abc-123/read"]
+
+ def test_multi_tenant_organizations_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="organizations",
+ )
+ assert verifier.issuer is None
+
+ def test_multi_tenant_consumers_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="consumers",
+ )
+ assert verifier.issuer is None
+
+ def test_multi_tenant_common_skips_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="common",
+ )
+ assert verifier.issuer is None
+
+ def test_specific_tenant_sets_issuer(self):
+ verifier = AzureJWTVerifier(
+ client_id="my-client-id",
+ tenant_id="12345678-1234-1234-1234-123456789012",
+ )
+ assert (
+ verifier.issuer
+ == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0"
+ )
diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py
index 4ecf622d96..14a81299e8 100644
--- a/tests/server/auth/test_jwt_provider.py
+++ b/tests/server/auth/test_jwt_provider.py
@@ -1099,3 +1099,20 @@ def test_jwt_verifier_requires_pyjwt(self):
except ImportError as e:
# If PyJWT not available, should get helpful error
assert "PyJWT is required" in str(e)
+
+
+class TestScopesSupported:
+ """Tests for the scopes_supported property on TokenVerifier."""
+
+ def test_defaults_to_required_scopes(self, rsa_key_pair: RSAKeyPair):
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ required_scopes=["read", "write"],
+ )
+ assert provider.scopes_supported == ["read", "write"]
+
+ def test_empty_when_no_required_scopes(self, rsa_key_pair: RSAKeyPair):
+ provider = JWTVerifier(
+ public_key=rsa_key_pair.public_key,
+ )
+ assert provider.scopes_supported == []
diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py
index 0419493f1d..5d56c5b5ca 100644
--- a/tests/server/auth/test_remote_auth_provider.py
+++ b/tests/server/auth/test_remote_auth_provider.py
@@ -483,3 +483,60 @@ async def test_resource_documentation_field(self):
data["resource_documentation"]
== "https://doc.my-server.com/resource-docs"
)
+
+ async def test_scopes_supported_overrides_metadata(self):
+ """Test that scopes_supported parameter overrides what's in metadata."""
+ token_verifier = StaticTokenVerifier(
+ tokens={
+ "test": {"client_id": "c", "scopes": ["read"]},
+ },
+ required_scopes=["read"],
+ )
+
+ provider = RemoteAuthProvider(
+ token_verifier=token_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://my-server.com",
+ scopes_supported=["api://my-api/read"],
+ )
+
+ mcp = FastMCP("test-server", auth=provider)
+ mcp_http_app = mcp.http_app()
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=mcp_http_app),
+ base_url="https://my-server.com",
+ ) as client:
+ response = await client.get("/.well-known/oauth-protected-resource/mcp")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scopes_supported"] == ["api://my-api/read"]
+
+ async def test_scopes_supported_defaults_to_verifier(self):
+ """Test that metadata uses verifier scopes_supported when parameter not set."""
+ token_verifier = StaticTokenVerifier(
+ tokens={
+ "test": {"client_id": "c", "scopes": ["read"]},
+ },
+ required_scopes=["read"],
+ )
+
+ provider = RemoteAuthProvider(
+ token_verifier=token_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://my-server.com",
+ )
+
+ mcp = FastMCP("test-server", auth=provider)
+ mcp_http_app = mcp.http_app()
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=mcp_http_app),
+ base_url="https://my-server.com",
+ ) as client:
+ response = await client.get("/.well-known/oauth-protected-resource/mcp")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scopes_supported"] == ["read"]