TL;DR: An MCP server that can touch real systems is an OAuth resource server with an unusually curious client: a language model. The current MCP authorization spec asks you to publish Protected Resource Metadata, accept only tokens issued for your own server, reject tokens in query strings, and challenge for scopes precisely. The security best-practices page adds per-client consent for proxies, SSRF controls, state-handle binding, and scope minimization. This article turns those requirements into an architecture, working code, a trade-off table, and a checklist. All requirement quotes come from the spec version published at 2026-07-28.

Why MCP servers need a real security design

Model Context Protocol (MCP) gives a model a uniform way to call tools, read resources, and use prompts exposed by a server. That uniformity is the appeal, and it is also the risk. A single MCP server often fronts a ticketing system, a database, a payments API, or a cloud account, and the caller is software that decides what to invoke partly from text it has read.

Teams tend to make one of two mistakes. The first is to treat the server as an internal script and ship it with a shared API key. The second is to bolt OAuth on late, copy a tutorial, and assume the token problem is solved. Both leave gaps that the MCP specification now names explicitly.

Two framing points before the detail:

  • Authorization is optional in MCP. The spec states "Authorization is OPTIONAL for MCP implementations." For HTTP-based transports it says implementations "SHOULD conform to this specification"; for STDIO they "SHOULD NOT follow this specification, and instead retrieve credentials from the environment." So the first design decision is which transport you are exposing, because it decides which threat model applies.
  • The spec moves. The version we read for this article is the 2026-07-28 revision (the date is taken from the spec URL; the pages themselves are undated). Check the revision your SDK targets, because requirements such as client registration approaches have changed between releases.

Threat model: who can hurt you and how

Start with a diagram of the parties. In the standard HTTP flow there are four: the user, the MCP client (which hosts the model), the MCP server acting as an OAuth resource server, and an authorization server that issues tokens.

mermaid
sequenceDiagram
    participant C as MCP client
    participant S as MCP server (resource server)
    participant A as Authorization server
    C->>S: Request without token
    S-->>C: 401 + WWW-Authenticate (resource_metadata, scope)
    C->>S: GET protected resource metadata (RFC 9728)
    S-->>C: authorization_servers, scopes_supported
    C->>A: Discover AS metadata (RFC 8414 or OIDC discovery)
    C->>A: Authorization request + resource parameter
    A-->>C: Access token (audience = MCP server)
    C->>S: Request with Authorization: Bearer token
    S->>S: Validate signature, expiry, audience, scopes
    S-->>C: Tool result

The attacks worth designing against, taken from the MCP security best-practices page, are:

  1. Confused deputy through MCP proxy servers that front a third-party authorization server.
  2. Token passthrough, where a server accepts or forwards tokens that were not issued to it.
  3. SSRF through attacker-controlled OAuth metadata URLs.
  4. State handle hijacking, where possession of an identifier is treated as proof of identity.
  5. Local server compromise through malicious startup commands or exposed localhost services.
  6. Over-broad scopes, which make any stolen token catastrophic.

The rest of this article takes them in the order you will meet them when building.

Step 2: Validate the audience, every time

This is the control that most "it works in the demo" servers skip. The spec says: "MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2." It also says servers "MUST only accept tokens that are valid for use with their own resources" and "MUST NOT accept or transit any other tokens."

The client half matters too. Clients "MUST implement Resource Indicators", and the resource parameter "MUST be included in both authorization requests and token requests" and use the server's canonical URI, for example https://mcp.example.com/mcp. Fragments are invalid in that URI.

Two more transport rules are easy to enforce in a middleware layer: "Access tokens MUST NOT be included in the URI query string", and authorization "MUST be included in every HTTP request from client to server" using the Authorization: Bearer <access-token> header. Invalid or expired tokens "MUST receive a HTTP 401 response."

Here is a compact Python implementation using PyJWT. We ran it locally against a generated RSA key: a token with the right audience passes, a token minted for a different audience is rejected with 401, and a missing scope produces a 403 carrying the scope the tool needs.

python
import jwt  # PyJWT

CANONICAL = "https://mcp.example.com/mcp"
ISSUER = "https://auth.example.com"

class AuthError(Exception):
    def __init__(self, status, scope=None):
        self.status, self.scope = status, scope

TOOL_SCOPES = {
    "search_orders": {"orders:read"},
    "refund_order": {"orders:read", "orders:refund"},
}

def verify(token, key):
    # PyJWT verifies signature, exp, iss and that aud contains CANONICAL
    try:
        return jwt.decode(
            token, key, algorithms=["RS256"],
            audience=CANONICAL, issuer=ISSUER,
            options={"require": ["exp", "aud", "iss", "sub"]},
        )
    except jwt.PyJWTError:
        raise AuthError(401)

def authorize_tool(claims, tool):
    granted = set(claims.get("scope", "").split())
    if TOOL_SCOPES[tool] - granted:
        raise AuthError(403, scope=" ".join(sorted(TOOL_SCOPES[tool])))
    return claims["sub"]  # identity comes from the verified token

In production, fetch the signing keys from the authorization server's JWKS endpoint and cache them rather than passing a static key. If your tokens are opaque rather than JWTs, use token introspection and still check the audience in the response.

Why "the same issuer" is not enough

Suppose your organization runs one authorization server for twenty internal APIs. If your MCP server only checks the issuer and signature, a token minted for the billing API is accepted by the MCP server, and a token minted for the MCP server could be replayed against billing. The security best-practices page lists the consequences of passthrough: security control circumvention, audit and accountability problems, and trust boundary violations. Audience checks are what keep those twenty APIs from becoming one flat permission pool.

Step 3: Never pass the caller's token downstream

A common MCP server calls other APIs on the user's behalf. The temptation is to take the inbound bearer token and attach it to the outbound request. The spec forbids this pattern: servers must not accept or transit tokens that were not issued for them.

The correct pattern is for your server to be a client of the downstream API in its own right. Depending on your authorization server, that means a separate token request for the downstream audience, using whichever delegation mechanism your identity provider supports (RFC 8693 token exchange is one option, though the MCP spec does not mandate it, so check your provider's documentation). The result is that every hop has a token with exactly one audience, and your audit log can say which service acted.

Step 4: Design scopes per tool and challenge precisely

Scopes are where MCP security stops being generic OAuth and becomes about your tools. The best-practices page describes the failure mode plainly: a stolen token carrying broad scopes such as files:* or admin:* because the server advertised everything in scopes_supported and the client asked for it all.

The mitigations it lists are a minimal initial scope set, incremental elevation through targeted WWW-Authenticate challenges, and emitting "precise scope challenges" rather than returning the full catalog.

The authorization spec fills in the runtime behavior:

  • A server lacking a needed scope "SHOULD respond with" HTTP 403 and a WWW-Authenticate: Bearer header containing error="insufficient_scope", the scope required, and resource_metadata.
  • Servers "SHOULD include all scopes required for the current operation in a single challenge", which avoids elevation loops.
  • Clients "MUST treat the scopes provided in the challenge as authoritative" and "MUST NOT assume any particular set relationship" between challenged scopes and scopes_supported.
  • Servers "MUST account for scope hierarchies, where a broader scope implies narrower ones."
  • Servers "SHOULD NOT include offline_access" in the challenge or in scopes_supported, and clients "MUST NOT assume refresh tokens will be issued."

A response for the refund tool from the earlier snippet would look like this:

http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:read orders:refund", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

A scoping heuristic that holds up

We suggest sizing scopes by blast radius rather than by API resource. Read-only lookups share one scope. Anything that mutates data gets its own. Anything that moves money, deletes, or changes permissions gets a dedicated scope that is never part of the initial request. This is our design advice, not a spec requirement, but it makes the spec's incremental elevation mechanism meaningful: users consent to the dangerous scope at the moment they need it.

Also keep the claim in the token separate from the decision. The best-practices page lists "treating token-claimed scopes as sufficient without server-side authorization logic" as a common mistake. A token that says orders:refund should not be able to refund an order belonging to another tenant. Scope answers "may this client class use this tool"; your application logic still answers "on which records".

Step 5: If you run an MCP proxy, fix the confused deputy

The classic confused-deputy scenario in the spec involves an MCP proxy server that uses a static client ID with a third-party authorization server, supports dynamic client registration, and relies on a consent cookie set by the third party. A malicious client can register itself, and because the consent cookie already exists from an earlier legitimate login, the third-party server skips the consent screen and returns an authorization code to the attacker's redirect URI.

The spec's answer is unambiguous: "MCP proxy servers MUST implement per-client consent and proper security controls." Concretely, the best-practices page asks for:

  • A consent registry checked before starting the third-party authorization flow.
  • A consent page that names the requesting client, shows scopes and the redirect_uri, includes CSRF protection, and blocks framing with frame-ancestors or X-Frame-Options: DENY.
  • Consent cookies using the __Host- prefix with Secure, HttpOnly, and SameSite=Lax, bound to the specific client_id.
  • Exact string matching for redirect_uri, with no patterns or wildcards.
  • Single-use OAuth state values with roughly a 10-minute expiry, and no consent cookie set until after the user approves.

If you are not building a proxy, you can skip this section. If you are, treat it as the part of the system most likely to be audited.

Step 6: Close the SSRF hole on the client side

SSRF is easy to overlook because it lives in the client, but anyone building an agent platform that connects to third-party MCP servers owns that client. A malicious server can return resource_metadata, authorization_servers, or endpoint URLs that point at 169.254.169.254, localhost, or internal hosts, and a naive client will fetch them.

The best-practices page says clients deployed to a server "MUST consider SSRF risks and implement appropriate mitigations when fetching OAuth-related URLs." Its recommendations include requiring HTTPS in production (with a loopback exception), blocking private and reserved ranges, applying the same validation to redirect targets, considering disabling automatic redirects, and using an egress proxy such as Smokescreen. It also warns that DNS resolution can change between check and use, and suggests pinning results. One line deserves repeating: "Avoid implementing IP validation manually." Use a vetted library or an egress proxy instead of a regular expression.

The same page covers validating the OAuth authorization URL before opening a browser: only http or https is allowed (plain http only for loopback in development), and javascript:, data:, file:, and vbscript: schemes must be rejected.

Step 7: Treat state handles as names, not credentials

Stateful tools return identifiers: a cart ID, a workflow ID, a draft ID. The spec's tools page puts it neatly: "For authenticated servers, a handle is a name, not a capability. The server should validate the caller's authorization against the handle on every call."

The best-practices page turns this into rules. Servers that implement authorization "MUST verify all inbound requests" and "MUST NOT treat possession of a state handle as authentication." It recommends secure random handles, expiry, and server-side binding to the authenticated user, for example keying storage on <user_id>:<handle> with the user ID taken from the verified token. The page notes it replaces an older "session hijacking" section for stateless designs and points to the 2025-11-25 revision for servers that still use server-assigned session IDs, so check which model your SDK follows.

Step 8: Secure the tool surface itself

Authorization decides who may connect. The tools page decides what a connected client can do, and its security section is short but specific. Servers must validate all tool inputs, implement proper access controls, rate limit invocations, and sanitize tool outputs. Clients should prompt for confirmation on sensitive operations, show tool inputs to the user before calling the server to avoid exfiltration, validate results before passing them to the model, implement timeouts, and log tool usage for audit.

Several practical consequences follow:

  • Write strict input schemas. The tool inputSchema is JSON Schema, and for tools with no parameters the spec recommends { "type": "object", "additionalProperties": false }. Apply the same discipline to real tools with additionalProperties: false and bounded strings, enums, and numeric ranges.
  • Use outputSchema where you can. The spec says servers "MUST provide structured results that conform to this schema" and clients "SHOULD validate" them.
  • Do not rely on annotations for safety. The spec states clients "MUST consider tool annotations to be untrusted unless they come from trusted servers." Annotations describe behavior for the user interface; enforcement belongs in your authorization layer.
  • Separate error types deliberately. Unknown tools and malformed requests are JSON-RPC protocol errors (for example -32602). API failures, validation errors, and business-logic errors should return isError: true with actionable text, which the spec says clients should pass to the model so it can self-correct.
  • Keep sensitive values out of headers. If you use x-mcp-header, the tools page says not to mark passwords, tokens, or PII with it, because header values are visible to network intermediaries.
  • Keep tools/list stable. The spec says it must not vary per connection, though the set may vary by the authorization presented on the request.

A human in the loop is described as a SHOULD in the spec, not a MUST. For destructive tools we would treat it as mandatory in your own policy anyway, and enforce it in the client you control.

Step 9: Local servers are a different problem

For stdio servers the spec says to take credentials from the environment instead of running the OAuth flow, which shifts the risk to what the process can do on the machine. The best-practices page recommends that clients show the exact command without truncation before one-click installs, require explicit approval, flag dangerous patterns such as sudo or rm -rf, and run servers in a sandbox with minimal privileges. Local servers should prefer stdio; if they expose HTTP, require an authorization token or use Unix domain sockets with restricted access.

Trade-offs: choosing a client registration and deployment approach

DecisionOptionStrengthCost or risk
Client registrationClient ID Metadata DocumentsSpec says authorization servers and clients SHOULD support them; no per-client registration stateFetching metadata documents brings SSRF exposure and needs a trust policy (allowlists, reputation)
Client registrationPre-registrationTight control over known clientsDoes not scale to open ecosystems
Client registrationDynamic Client RegistrationWorks with older clientsSpec calls it deprecated and retained for backwards compatibility; amplifies confused-deputy risk in proxies
DeploymentDirect resource serverSimple trust chain; audience check is localYou must run or integrate an authorization server
DeploymentMCP proxy to third-party ASReuses existing identity providerPer-client consent, redirect URI matching and cookie hardening become your responsibility
ScopesFew coarse scopesEasy to reason aboutLarge blast radius when a token leaks
ScopesPer-tool scopes with step-upSmall blast radiusMore consent prompts; client retry logic needed
Local transportstdioLimited access to the client processRuns with client privileges; needs sandboxing
Local transportLocalhost HTTPReusable by multiple clientsNeeds auth token; exposed to DNS rebinding if unguarded

Common mistakes

  • Verifying the signature and issuer but not the audience.
  • Forwarding the inbound bearer token to a downstream API.
  • Advertising every scope in scopes_supported and requesting them all up front.
  • Accepting a token in a query parameter because a particular client library makes it convenient.
  • Using wildcard or prefix matching for redirect URIs.
  • Returning guessable or sequential handles and trusting them as identity.
  • Assuming a tool annotation means the tool is safe.
  • Letting the agent platform fetch any OAuth metadata URL a server hands it.
  • Logging full tokens or tool arguments containing personal data.
  • Hardcoding the spec revision in your head: re-read the version your SDK targets before every upgrade.

Production checklist

Discovery and tokens

  • [ ] RFC 9728 metadata served; 401 includes resource_metadata.
  • [ ] Audience validated against the canonical server URI on every request.
  • [ ] Tokens accepted only from the Authorization header, never the query string.
  • [ ] No inbound token ever forwarded downstream; separate downstream credentials in place.
  • [ ] Expired or invalid tokens return 401; insufficient scope returns 403 with a precise challenge.

Scopes and tools

  • [ ] Scopes sized by blast radius; destructive scopes excluded from the initial request.
  • [ ] Application-level checks for tenant and record ownership independent of scope.
  • [ ] Strict input schemas, rate limits, output sanitization, audit logging.
  • [ ] Confirmation step for sensitive tools enforced in clients you control.

Proxies and clients

  • [ ] Per-client consent registry, hardened cookies, exact redirect URI matching.
  • [ ] SSRF controls or an egress proxy on every OAuth-related fetch.
  • [ ] RFC 9207 issuer validation implemented in clients.
  • [ ] State handles random, expiring, and bound to the verified user.

Operations

  • [ ] Local servers sandboxed; install commands shown in full.
  • [ ] Monitoring for repeated 401 and 403 patterns and elevation loops.
  • [ ] A named owner who reviews each new MCP spec revision.

Where this fits in a wider engineering programme

MCP security is mostly identity and API security applied to a new kind of caller. Teams that already run disciplined API gateways, secrets management, and audit logging have a head start; teams that do not will find MCP exposes the gaps quickly. At Syslabs we treat this as an integration and identity design problem first: decide the audience and scopes, wire them into the gateway and the authorization server, then add the model-specific controls on top. We do not claim a particular threat is rare or common here, because we have not measured it, and the sources cited below are the places to track changes as the specification evolves.

If your platform is heading toward production agent workloads, reviewing your authorization server's support for resource indicators and metadata discovery is a sensible first week of work.

Conclusion

Securing an MCP server comes down to a handful of habits applied consistently: advertise your identity through standard metadata, accept only tokens issued for you, never relay tokens, ask for the smallest scope that works, and verify the user behind every state handle. The specification now spells these out clearly enough to turn into tests. Write those tests before you add the tenth tool, not after.

If you want a second pair of eyes on your authorization design, scopes, or agent platform integration, book a meeting with the Syslabs team.

References

  • Model Context Protocol. Authorization (revision 2026-07-28). https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
  • Model Context Protocol. Security best practices. https://modelcontextprotocol.io/specification/latest/basic/securitybestpractices
  • Model Context Protocol. Tools. https://modelcontextprotocol.io/specification/latest/server/tools
  • RFC 9728, OAuth 2.0 Protected Resource Metadata. https://www.rfc-editor.org/rfc/rfc9728
  • RFC 8707, Resource Indicators for OAuth 2.0. https://www.rfc-editor.org/rfc/rfc8707
  • RFC 8414, OAuth 2.0 Authorization Server Metadata. https://www.rfc-editor.org/rfc/rfc8414
  • RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage. https://www.rfc-editor.org/rfc/rfc6750
  • RFC 9207, OAuth 2.0 Authorization Server Issuer Identification. https://www.rfc-editor.org/rfc/rfc9207