How to Add Authentication to an MCP Server
The pieces an MCP server has to implement before an assistant can sign in to it: protected resource metadata, a proper 401, token validation with an audience check, and scopes. Plus when to reuse an authorization server and when a stdio server needs none of it.
7 min read
To add authentication to an MCP server that is reached over HTTP, you make it an OAuth 2.1 resource server: publish a protected resource metadata document that names your authorization server, answer every unauthenticated request with a 401 and a WWW-Authenticate header pointing at that document, validate every bearer token on every request, including that it was issued for your server, and check scopes before each operation. Sign-in itself belongs to an authorization server, which can be an identity provider you already run or one you build. A server your client launches locally over stdio needs none of this; the specification says it should read credentials from its environment. This guide follows the MCP authorization specification (opens in a new tab), revision 2026-07-28.
For what the same flow looks like from the chair of the person approving it, see OAuth for MCP servers: how sign-in works. For the security rules around it, see MCP security best practices.
First decide: stdio or HTTP
Authorization is optional in MCP, and the transport decides which kind you need. Over HTTP, the specification says you should conform to its OAuth flow. Over stdio, it says you should not, and should take credentials from the environment. A stdio server is a child process of the client, so only that client can talk to it; the credential it needs is the one for whatever it calls upstream.
{
"mcpServers": {
"example": {
"command": "npx",
"args": ["-y", "example-mcp-server"],
"env": { "EXAMPLE_API_KEY": "set-this-outside-version-control" }
}
}
}In the server, read the variable at start-up, fail with a clear message if it is missing, and never log it. Everything else in this guide is for HTTP servers.
Reuse an authorization server, or build one?
Your MCP server is the resource server. The authorization server signs people in and issues tokens. The specification allows it to be hosted with your MCP server or entirely separate, and lists it in your metadata either way.
- Reuse one if your organisation already has an identity provider. You inherit its sign-in, multi-factor and account lifecycle. Check three things before you commit: that its metadata advertises PKCE through
code_challenge_methods_supported(clients must refuse to proceed without it), that it can issue tokens for a specific resource so you can check the audience, and how MCP clients will register with it: a Client ID Metadata Document, pre-registration, or dynamic client registration. - Build one if your product has its own accounts and no provider fits. It is a small surface (metadata, an authorize page with consent, a token endpoint, registration), but it is security code, and the requirements below all become yours.
Piece 1: protected resource metadata
MCP servers must implement RFC 9728 (opens in a new tab) protected resource metadata, and the document must list at least one authorization server. resource is the only field RFC 9728 requires; scopes_supported and resource_name are recommended.
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["read", "write"],
"bearer_methods_supported": ["header"],
"resource_name": "Example"
}For a server at https://mcp.example.com/mcp, clients without a header to follow try the path-inserted address above first (opens in a new tab), then /.well-known/oauth-protected-resource at the root. Serving the same document at both costs nothing. The resource value must be identical to the identifier the client used, so use one canonical URI everywhere: lower-case scheme and host, no fragment, and no trailing slash unless it matters.
Keep scopes_supported to the minimal set for basic use. The specification tells clients with no other guidance to request everything listed there, so a long list becomes a broad first token.
Piece 2: a 401 that says where to sign in
A client learns that it can sign in only from a 401. Return one for any request without a valid token, including the very first initialize call, and include the metadata address. The scope parameter is optional and tells the client what to ask for.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp",
scope="read"A common mistake is to let initialize succeed for anyone and fail only inside tool calls. The client then connects happily, every tool returns an error, and nothing ever triggers the sign-in.
Piece 3: validate every token, including its audience
- Validate on every request. The specification requires authorization on every HTTP request, and possession of a session or state handle is never proof of who is calling.
- Check it is genuine and current: verify the signature against the issuer’s keys, or ask the issuer through introspection for opaque tokens. Invalid or expired tokens must get a
401. - Check the audience. The server must reject tokens that were not issued specifically for it, for example by checking that the audience claim names your canonical URI. Clients are required to send a
resourceparameter so authorization servers can bind tokens this way. - Never pass a token through. If your server calls another API, it gets its own token for that API. Forwarding the client’s token is forbidden, because it bypasses the downstream service’s controls and makes it impossible to tell clients apart in any log.
- Map the token to a user on your side, and apply that user’s own permissions as well as the token’s scopes.
Piece 4: scopes, and a 403 that asks for more
When a valid token lacks the scope an operation needs, the specification says to answer 403 with error="insufficient_scope", the scopes required, and the metadata address. Put every scope the operation needs in one challenge, so the client can re-authorize once rather than in rounds.
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="File write permission required for this operation"The security guidance (opens in a new tab) lists the usual mistakes: wildcard scopes such as * or full-access, publishing every possible scope, and treating the scopes claimed in a token as enough without your own authorization logic behind them.
If you run the authorization server too
- Publish RFC 8414 (opens in a new tab) metadata at
/.well-known/oauth-authorization-server:issuer,authorization_endpoint,token_endpoint,response_types_supportedandcode_challenge_methods_supportedwithS256, plusclient_id_metadata_document_supportedor aregistration_endpointfor the registration methods you accept. - Serve every endpoint over HTTPS, accept only
localhostor HTTPS redirect URIs, and match redirect URIs exactly against the registered value. - On the consent page, name the client, show the redirect hostname clearly, warn more for
localhost-only redirects, and prevent the page being framed. - Include the
issparameter in authorization responses and advertise it; the specification expects this to become mandatory. - Issue short-lived access tokens, and if you issue refresh tokens to public clients, rotate them on every use.
- If you fetch Client ID Metadata Documents, treat the URL as untrusted input: it is a server-side request forgery risk (opens in a new tab).
Check it from the command line
# No token: expect 401 and a WWW-Authenticate header with resource_metadata
curl -si -X POST https://mcp.example.com/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | head -5
# The metadata the header points at
curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcpThen connect a real client end to end, and test the refusals too: a token for a different resource, an expired token, and a token missing a scope should each fail with the right status.
A worked example: fenbs
fenbs, a task board where AI assistants are members with roles, took the “build one” route because it has its own accounts. The issuer is the same origin as the MCP endpoint. Its protected resource metadata lists that origin and the scopes read, write and comment, and is answered at both the root and the path-inserted address. Its authorization server metadata advertises S256 only, the authorization code grant only, and no client secrets, because every MCP client is treated as a public client. A request with no credential gets the 401 above, for every method.
Two choices are worth copying. A token is checked against its scopes and against its owner’s role on the board, and the narrower wins, so scopes are a ceiling rather than a grant. And every change is recorded as “Claude via” the person the token belongs to, which is only possible because the server never forwards tokens and always knows whose token it holds. fenbs gives a signed-in assistant an access token that lasts an hour and a refresh token that renews it; revoking the connection in Settings ends both.
Related
The same flow from the user’s side: how sign-in works. What to demand of a server you did not build: what to look for in an MCP server. A working example to connect to: the fenbs MCP guide.