Back to all posts
Security
8 min read

Only 8.5% of MCP Servers Use OAuth: A Practical Security Guide for 2026

DevToolLab Team

DevToolLab Team

July 13, 2026

Only 8.5% of MCP Servers Use OAuth: A Practical Security Guide for 2026

In October 2025, Astrix Security scanned 5,205 open-source MCP servers to see how they actually handle authentication, not how the spec says they should. 88% require some kind of credential, 53% of all servers scanned rely on static API keys or personal access tokens that never expire, and only 8.5% use OAuth. Of the servers that do take API keys, 79% pull them straight from environment variables.

That gap isn't theoretical. In June, Asana took its MCP server offline after a tenant-isolation bug let AI assistants pull task data and files across roughly 1,000 customer organizations for two weeks (The Register). In October, JFrog disclosed CVE-2025-6514, a CVSS 9.6 remote code execution bug in mcp-remote (437,000+ downloads) caused by the client trusting a server-supplied authorization_endpoint URL and passing it to a shell. Neither needed a nation-state attacker, just a server that skipped a validation step the spec already called for.

This post covers what the MCP Authorization spec actually requires and how to add real OAuth 2.1 support to a Python MCP server, with working code.

Why "Just Check an API Key" Isn't Enough

A normal web app has one trust boundary: a human logs in through a browser and every request after that carries their session. MCP breaks that. The caller usually isn't a browser, it's Claude Desktop, Cursor, or a custom agent acting on a user's behalf. One MCP server can serve many different clients over time, each needing its own scoped identity, not just a shared secret. And a lot of MCP servers are themselves OAuth clients to something else (a GitHub MCP server calling GitHub's API), which means getting the resource-server/client boundary wrong creates what the spec calls a confused deputy vulnerability.

If you've read our guide on building an MCP server in Python or our roundup of the best MCP servers, this is the part those posts don't cover: making sure only the right callers can reach your tools.

The OAuth 2.1 Pieces MCP Actually Uses

The MCP Authorization spec is explicit about being a "selected subset" of existing OAuth 2.1 machinery, so you can use libraries and identity providers that already exist. Authorization is technically optional (servers over stdio pull credentials from the environment instead), but any server exposed over HTTP that isn't fully public needs it.

SpecWhat it does
OAuth 2.1 (draft-ietf-oauth-v2-1)Core flow: PKCE required, no implicit grant, no password grant
RFC 9728 Protected Resource MetadataMCP server tells clients where its auth server lives
RFC 8707 Resource IndicatorsBinds a token to one specific MCP server so it can't be replayed elsewhere
Client ID Metadata Documents (added Nov 2025)Client identifies itself via a hosted HTTPS JSON document instead of pre-registering

The November 2025 revision also added a formal step-up flow: a 403 with WWW-Authenticate: error="insufficient_scope" when a token doesn't cover a tool it just tried to call. If your server currently returns a bare 403, that's a gap worth closing.

The part that actually determines whether your server is secure is the resource-server side: validating the token on every request. That's the how-to below.

Should You Run Your Own Authorization Server?

Almost certainly not. The spec deliberately leaves the authorization server "beyond the scope of this specification" because MCP servers are meant to plug into an identity provider you already trust. WorkOS, Auth0, Stytch, Okta, and Clerk all issue standards-compliant OAuth 2.1 tokens today, several with MCP-specific guides. Either way, your MCP server only needs to play one role: OAuth 2.1 resource server, which validates tokens it never issued.

How to Secure Your MCP Server With OAuth 2.1

This uses the official Python SDK (mcp, v1.28 stable). On the SDK's v2.0 beta, FastMCP is renamed to MCPServer (from mcp.server import MCPServer), but the constructor arguments below are identical.

1. Install dependencies

Bash
uv add "mcp[cli]" pyjwt
# or
pip install "mcp[cli]" pyjwt

2. Write a token verifier

If your identity provider issues JWT access tokens (WorkOS, Auth0, and Okta all do by default), verify them locally against the provider's JWKS with PyJWT, no per-request network call needed:

Python
import jwt
from jwt import PyJWKClient
from mcp.server.auth.provider import AccessToken, TokenVerifier


class JWTTokenVerifier(TokenVerifier):
    """Verifies JWT access tokens locally against the issuer's JWKS."""

    def __init__(self, issuer: str, audience: str, jwks_url: str):
        self.issuer = issuer
        self.audience = audience
        # cache_jwk_set=True (default) caches the JWKS for 5 minutes (lifespan=300)
        self.jwks_client = PyJWKClient(jwks_url)

    async def verify_token(self, token: str) -> AccessToken | None:
        try:
            signing_key = self.jwks_client.get_signing_key_from_jwt(token)
            claims = jwt.decode(
                token,
                signing_key.key,
                algorithms=["RS256"],
                audience=self.audience,
                issuer=self.issuer,
            )
        except jwt.PyJWTError:
            return None

        return AccessToken(
            token=token,
            client_id=claims.get("azp", claims.get("client_id", "unknown")),
            scopes=claims.get("scope", "").split(),
            expires_at=claims.get("exp"),
            resource=self.audience,
            subject=claims.get("sub"),
            claims=claims,
        )

The audience=self.audience argument does the RFC 8707 audience check for you. PyJWT raises InvalidAudienceError (a subclass of PyJWTError) if the token's aud claim doesn't match, which is the single most commonly skipped validation step in the spec.

If your provider issues opaque tokens instead of JWTs, swap this for a TokenVerifier that calls the auth server's RFC 7662 introspection endpoint. The SDK ships a working example in examples/servers/simple-auth.

3. Wire it into your server

Python
from mcp.server.fastmcp import FastMCP
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl

token_verifier = JWTTokenVerifier(
    issuer="https://your-tenant.workos.com",
    audience="https://mcp.yourcompany.com/mcp",
    jwks_url="https://your-tenant.workos.com/sso/jwks/client_your_id",
)

mcp = FastMCP(
    "Internal Tools",
    token_verifier=token_verifier,
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://your-tenant.workos.com"),
        resource_server_url=AnyHttpUrl("https://mcp.yourcompany.com/mcp"),
        required_scopes=["mcp:tools-basic"],
    ),
)


@mcp.tool()
def list_open_tickets(project: str) -> list[str]:
    """List open tickets for a project."""
    return fetch_tickets(project)

AuthSettings makes the SDK publish RFC 9728 metadata and return a proper 401 with a WWW-Authenticate header automatically. Keep required_scopes narrow (not ["*"]) since a stolen token's blast radius is exactly as big as its scope list.

4. Read the caller's identity inside a tool

Python
from mcp.server.auth.middleware.auth_context import get_access_token


@mcp.tool()
def whoami() -> str:
    """Report which client and scopes are calling this tool."""
    token = get_access_token()
    if token is None:
        return "anonymous"
    return f"client={token.client_id} scopes={','.join(token.scopes)} sub={token.subject}"

5. Run it and test the 401 flow

Python
if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="127.0.0.1", port=8000)

An unauthenticated request should come back with the discovery header:

curl -i http://127.0.0.1:8000/mcp
http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

The cURL command generator is faster for building the follow-up authenticated request once you have a real token. Once you get one back, decode it with the JWT decoder to confirm the aud claim matches your resource_server_url exactly before you spend an hour debugging why validation keeps failing.

The Security Checklist

Most real-world MCP breaches map to one of these:

  1. Never pass tokens through. If your server calls a downstream API, exchange for a separate token as its own OAuth client. Forwarding the client's token breaks the downstream audit trail and rate limiting.
  2. Validate the audience on every request, even if the signature is valid. This is the single most common mistake and the one that turns a leaked token into a token that works everywhere.
  3. Require PKCE with S256 and refuse to proceed if the auth server doesn't advertise code_challenge_methods_supported.
  4. Match redirect URIs exactly. No wildcard or prefix matching, this is exactly where CVE-2025-6514 and confused-deputy attacks live.
  5. Keep access tokens short-lived and rotate refresh tokens on every use for public clients.
  6. Minimize scopes. Publish the smallest required_scopes set that works and step up incrementally when a tool needs more.

Keep issuer URLs and secrets in environment variables, not hardcoded. The .env file generator scaffolds a clean .env.example, and the dotenv linter catches stray secrets before they hit a commit. If you need to test locally against a mock issuer, the JWT signing key generator spins up an RS256 key pair in your browser.

Conclusion

The gap between "MCP server that works" and "MCP server that's safe to expose" is almost entirely the auth layer, and per Astrix's numbers, over 91% of deployed servers haven't closed it. Closing it doesn't mean building your own authorization server. It means picking an identity provider that already speaks OAuth 2.1, writing one TokenVerifier class that checks signature, expiry, and audience, and never passing a token through to anything it wasn't issued for. Start with the audience check even if you do nothing else today, it's the single line of validation the spec's own security document flags as the most commonly skipped.

Related Posts

Non-Human Identity Security: Your API Keys Outnumber Your Employees 80 to 1

OWASP just shipped its first Non-Human Identity Top 10. Your API keys, service accounts, and AI agents outnumber your employees 80 to 1, and almost none of them have an owner. Here's the fix, with real code.

By DevToolLab Team

Passkeys Explained: The Developer's Complete Guide to Passwordless Authentication in 2026

5 billion passkeys are now in active use. Google reports a 99.9% lower account compromise rate compared to passwords. Here is the complete technical breakdown of how passkeys work and how to implement them - from WebAuthn registration to production deployment.

By DevToolLab Team

JWT Tokens: Complete Security Guide for Developers

Master JWT token security with best practices, common vulnerabilities, and implementation guidelines for secure authentication.

By DevToolLab Team