# How to Add Authentication to an MCP Server

> How to add authentication to an MCP server: OAuth 2.1 bearer tokens, audience checks, and a 401 challenge. Tested Python and TypeScript.

By Tayyab Javed, freelance AI agent developer (https://hireagentic.dev) · Published October 12, 2026 · Tutorial · 6 min read
Canonical URL: https://hireagentic.dev/blog/mcp-server-authentication

Add authentication to an MCP server by treating the HTTP endpoint as an OAuth 2.1 resource server. On every request, read `Authorization: Bearer`, check that the token was issued for this server, and answer 401 when it was not. The MCP server does not sign the user in and does not mint the token. A stdio server skips this flow and reads secrets from the environment.

By Tayyab Javed, AI agent developer.

## Quick answer

- Spec revision **2026-07-28**. HTTP authorization is optional. When you turn it on, follow this spec. Stdio should not.
- Your process is the **resource server**. Auth0, Keycloak, Entra, or your own issuer is the authorization server.
- A missing or bad token is **401** with a `WWW-Authenticate` header that points at the RFC 9728 metadata document. A token that lacks a required scope is **403**.
- Refuse a token whose audience is a different URL. That check is RFC 8707. Clients send the `resource` parameter when they ask for the token.

## What the server has to verify

The client sends the access token in the Authorization header on every HTTP request, never in the query string. Your verifier reports client id, scopes, expiry, and resource (the `aud` claim). The SDK rejects a missing expiry, an expired token, a foreign resource, and a missing scope. I ran the samples on October 12, 2026 against Python `mcp` 2.3.0 and `@modelcontextprotocol/server` 2.3.1. Spec: [authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization).

## Python bearer gate

`TokenVerifier` is one async method. `validate_token_resource=True` turns on the audience check. The token below is a stand-in for a JWT or an RFC 7662 introspection call. Both still return an `AccessToken`.

```python
from pydantic import AnyHttpUrl

from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings

RESOURCE = "http://127.0.0.1:8000/mcp"

class StaticTokenVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        if token != "alice-token":
            return None
        return AccessToken(
            token=token,
            client_id="alice",
            scopes=["notes:read"],
            resource=RESOURCE,
            expires_at=2_000_000_000,
        )

mcp = MCPServer(
    "Notes",
    token_verifier=StaticTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.example.com"),
        resource_server_url=AnyHttpUrl(RESOURCE),
        required_scopes=["notes:read"],
        validate_token_resource=True,
    ),
)

@mcp.tool()
def whoami() -> str:
    """Report which OAuth client is calling."""
    token = get_access_token()
    if token is None:
        return "anonymous"
    return token.client_id + " [" + ", ".join(token.scopes) + "]"

if __name__ == "__main__":
    mcp.run(transport="streamable-http")
```

I served this app and called `whoami`. No header: **401**, with `WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8765/.well-known/oauth-protected-resource/mcp"`. `Bearer alice-token` returned `alice [notes:read]`. A token whose `resource` was `https://other.example/mcp` was 401, and the tool did not run. `GET /.well-known/oauth-protected-resource/mcp` returned the resource, `authorization_servers`, and `scopes_supported`. Pass `token_verifier` and `auth` together or the constructor raises. `get_access_token()` is `None` on stdio.

## TypeScript bearer gate

`createMcpHandler` does not read the Authorization header. `authInfo` is pass-through. `requireBearerAuth` is the gate. If it returns a `Response`, send that. Otherwise pass `authInfo` into `handler.fetch`.

```typescript
import {
    createMcpHandler,
    getOAuthProtectedResourceMetadataUrl,
    McpServer,
    OAuthError,
    OAuthErrorCode,
    requireBearerAuth
} from '@modelcontextprotocol/server';

const mcpServerUrl = new URL('https://mcp.example.com/mcp');

const verifier = {
    async verifyAccessToken(token) {
        if (token !== 'alice-token') {
            throw new OAuthError(OAuthErrorCode.InvalidToken, 'Unknown token');
        }
        return {
            token,
            clientId: 'alice',
            scopes: ['notes:read'],
            expiresAt: Math.floor(Date.now() / 1000) + 3600,
            resource: new URL(mcpServerUrl.href)
        };
    }
};

const gate = requireBearerAuth({
    verifier,
    requiredScopes: ['notes:read'],
    resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
    expectedResource: mcpServerUrl
});

const handler = createMcpHandler(({ authInfo }) => {
    const server = new McpServer({ name: 'notes', version: '1.0.0' });
    server.registerTool(
        'whoami',
        { description: 'Name the authenticated caller' },
        async () => ({
            content: [{ type: 'text', text: (authInfo && authInfo.clientId) || 'anonymous' }]
        })
    );
    return server;
});

export default {
    async fetch(request) {
        const auth = await gate(request);
        if (auth instanceof Response) return auth;
        return handler.fetch(request, { authInfo: auth });
    }
};
```

On 2.3.1 the same gate answered **401** `Missing Authorization header`, 401 `Unknown token`, 401 `Token was not issued for this resource` for a different audience, and **403** `insufficient_scope` when `notes:read` was missing. `Bearer alice-token` reached the tool, which returned the text `alice`. Throw `OAuthError` for a bad token. Any other exception becomes HTTP 500. Set `expiresAt`, or the gate answers 401. Express users: `@modelcontextprotocol/express` was 2.0.2 that day, not 2.3.1, and its middleware does forward `expectedResource`. Mount it on `/mcp`. Handlers then read `ctx.http.authInfo`.

## What to leave out of the tool

Keep secrets out of the tool description. The model reads that text. Leave login and token minting on the authorization server. The Python SDK still accepts `auth_server_provider`, and the current docs tell new servers to skip it. Old TypeScript helpers that issue tokens live in `@modelcontextprotocol/server-legacy/auth`. Rate limits are still your code. The spec does not add them.

The server under this gate is [how to build an MCP server](https://hireagentic.dev/blog/how-to-build-an-mcp-server). The public URL is [how to deploy an MCP server](https://hireagentic.dev/blog/deploy-mcp-server). The rest of the failure list is the [MCP production guide](https://hireagentic.dev/blog/mcp-model-context-protocol-production-guide).

## Frequently Asked Questions

### Does MCP authentication use OAuth?

On HTTP, yes. Revision 2026-07-28 uses OAuth 2.1 bearer tokens. The MCP server verifies them. A separate authorization server issues them.

### Does a stdio MCP server need OAuth?

No. The spec keeps stdio off this HTTP flow. The process that launched the server is the boundary, and credentials come from the environment.

### Where does the MCP access token go?

In the Authorization header on every HTTP request. A missing, expired, or wrong-audience token gets 401. A token missing a required scope gets 403.

## Conclusion

Publish the metadata document, verify the bearer token, and refuse every token that names a different resource. Then rate-limit the same route. If you want that gate in front of tools that write to a real system, hire me and we will sketch the issuer, the scopes, and which calls need a person.

### Hire Tayyab to put auth on the server

Free 30-minute scoping call. We will name the issuer, the audience URL, and the scopes each tool requires.

[Book a Scoping Call](https://cal.com/tayyab-javed-ubhldo/30min)
