The MCP spec's security rules that servers skip
The Model Context Protocol puts much of its security on implementers, in MUST and MUST NOT language a working server can ignore. Token audiences, owned state handles, SSRF, command allowlists and least scope, as a checklist.
By George OnyangoSep 15, 202613 min read
Most MCP servers are built from a tutorial. The tutorial gets a tool registered, a client connected and a result back, and it stops there, because that is where the demo works. What it rarely covers is the part of the specification written in MUST and MUST NOT — a long, specific security section that a working server can ignore entirely without anything visibly breaking.
That gap matters more than it would for most protocols, because MCP deliberately puts a great deal of its safety on implementers. The clearest illustration came this spring. OX Security spent five months researching how MCP's STDIO transport launches servers, producing more than 30 responsible disclosures and ten or more Critical and High CVEs across the ecosystem. The root cause was that the SDK runs whatever command it is configured with, before checking whether that command is an MCP server at all. Anthropic's response during coordinated disclosure was that the behaviour is intentional: the model is secure when developers restrict which commands can appear, and input sanitisation is the developer's responsibility.1MCP by Design: RCE Across the AI Agent EcosystemResearch note on OX Security's findings; vendor response and mitigations Open source (opens in a new tab)
Whatever you think of that position, it tells you where you stand. The protocol gives you the mechanism. The safety is yours. Below are the rules from the current specification that are easiest to skip without noticing, and what compliance looks like in Go.
1. Only accept tokens that were issued to you
The authorization specification is unambiguous. MCP servers "MUST validate that access tokens were issued specifically for them as the intended audience." They "MUST only accept tokens that are valid for use with their own resources." And: "MCP servers MUST NOT accept or transit any other tokens."2AuthorizationVersion 2026-07-28 · Token Handling; Resource Parameter Implementation; Scope Challenge Handling Open source (opens in a new tab)
That last sentence rules out one of the most common MCP server designs: a thin wrapper that takes whatever bearer token the client sends and forwards it to the API behind it. The security guidance names this token passthrough and lists what it breaks — the server can't tell clients apart, downstream logs show the wrong identity, rate limiting and validation keyed on the audience get bypassed, and a token accepted by several services lets an attacker who compromises one reach the rest.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
// An MCP server is an OAuth resource server. A token minted for some other API
// is not a credential here, however valid its signature is.
func (s *Server) authenticate(r *http.Request) (Principal, error) {
raw, ok := bearerToken(r)
if !ok {
return Principal{}, errUnauthenticated // 401
}
claims, err := s.verifier.Verify(r.Context(), raw) // signature, issuer, expiry
if err != nil {
return Principal{}, fmt.Errorf("verify token: %w", err)
}
if !slices.Contains(claims.Audience, s.canonicalURI) {
return Principal{}, errWrongAudience // 401: issued to someone else
}
return Principal{UserID: claims.Subject, Scopes: claims.Scopes()}, nil
}
// Calling an upstream API is a separate authorization with a separate token.
// Forwarding the caller's bearer token would be token passthrough.
func (s *Server) listOrders(ctx context.Context, p Principal) ([]Order, error) {
tok, err := s.tokens.For(ctx, p, s.ordersAudience, "orders.read")
if err != nil {
return nil, fmt.Errorf("upstream token: %w", err)
}
return s.orders.List(ctx, tok)
}The client side of the same rule is the resource parameter. Clients MUST include it in both authorization and token requests, identifying the MCP server the token is for — and MUST send it whether or not the authorization server supports it.2AuthorizationVersion 2026-07-28 · Token Handling; Resource Parameter Implementation; Scope Challenge Handling Open source (opens in a new tab) Together, the two halves mean a token is bound to one server at issuance and checked for that binding at use. If this looks familiar, it's the single-hop version of the argument in the agent protocols are standardised, your authorization isn't: forward identity, never the raw credential.
2. A handle is not a login, and a connection is not a session
The 2026-07-28 revision made MCP explicitly stateless: "all the information needed to process a request is contained in the request itself." Servers MUST NOT rely on prior requests over the same connection to establish context such as client identity, and state spanning multiple requests MUST be referenced by an explicit identifier the client passes each time. The specification is careful to add that an open connection, including a STDIO process, "is not a conversation or session."4OverviewStatelessness; _meta per-request protocol fields Open source (opens in a new tab)
Two consequences follow, and both are easy to get wrong. First, anything a client says about itself in request metadata is self-reported. The clientInfo field is for display, logging and debugging, and implementations SHOULD NOT rely on it for security decisions.4OverviewStatelessness; _meta per-request protocol fields Open source (opens in a new tab)
Second, the explicit identifiers that replace sessions — a cart id, a workflow id — become a new thing to steal or guess. The guidance here is direct: servers "MUST NOT treat possession of a state handle as authentication," SHOULD generate handles with a secure random number generator, and SHOULD bind them to the authenticated user by keying stored state as <user_id>:<handle>, with the user id taken from the verified token rather than from anything the client supplies.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
// Handles are random and opaque, and stored state is keyed by the verified
// user as well as the handle. A leaked or guessed handle presented by anyone
// else is simply not found.
func newHandle() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate handle: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *CartStore) Get(ctx context.Context, p Principal, handle string) (*Cart, error) {
// p comes from authenticate(), never from the request arguments.
cart, ok := s.carts.Load(p.UserID + ":" + handle)
if !ok {
// Not found, rather than forbidden: a different answer for "exists
// but isn't yours" would confirm the handle to whoever guessed it.
return nil, errNotFound
}
return cart.(*Cart), nil
}This is the same failure we wrote about in concurrency-safe isn't tenant-safe, one layer out. There, a value in process memory had no owner. Here, a value in a store has an id but no owner. In both cases the fix is to make ownership part of the key, so the wrong principal can't even address the data.
3. If you proxy a third-party API, consent is per client
Many MCP servers are proxies: they present MCP tools and act as a single OAuth client to some third-party API underneath. The specification describes a confused deputy attack against exactly this shape. When the proxy uses one static client id upstream, lets MCP clients register dynamically, and the upstream authorization server remembers consent in a cookie, an attacker can register their own client, send the user a crafted link, ride the existing consent cookie past the upstream consent screen, and receive an authorization code at their own redirect URI.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
The fix is a consent step the proxy owns, running before it forwards anyone upstream. MCP proxy servers MUST keep a registry of approved client ids per user and check it first. The consent page MUST name the requesting client, show the scopes and the redirect URI, carry CSRF protection and refuse to be framed. Redirect URIs MUST match exactly, never by pattern. And the OAuth state value MUST NOT be set until after the user has approved — setting it earlier lets an attacker skip the screen entirely.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
4. Every URL a server gives you is untrusted input
An MCP client doing OAuth discovery fetches URLs that a server supplied: the resource metadata URL from a WWW-Authenticate header, the authorization server list, and the endpoints listed in its metadata. A malicious server can point any of them inward — at 192.168.1.1, at localhost:6379, or at the cloud metadata service on 169.254.169.254, which often hands out credentials. That's server-side request forgery with your MCP client as the proxy. Clients deployed on servers MUST consider it; the guidance is to require HTTPS, block private, loopback and link-local ranges, validate every redirect hop, and route discovery through an egress proxy.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
The specification also warns against hand-rolled IP validation, because encoding tricks such as octal, hex and IPv4-mapped IPv6 routinely slip past custom parsers.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab) The robust place to check in Go is the dialer, after DNS resolution, against a parsed address rather than a string:
// Checked at connect time against the address actually being dialled, not the
// hostname in the URL. A DNS answer that changes between validation and use
// can't route the request inward, and every redirect hop passes through here.
func egressDialer() *net.Dialer {
return &net.Dialer{
Timeout: 5 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
ap, err := netip.ParseAddrPort(address)
if err != nil {
return fmt.Errorf("parse dial address: %w", err)
}
ip := ap.Addr().Unmap() // IPv4-mapped IPv6 is the same address
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() {
return fmt.Errorf("blocked outbound connection to %s", ip)
}
return nil
},
}
}
var discoveryClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{DialContext: egressDialer().DialContext},
}Dial-time checking also closes the DNS-rebinding gap the specification describes, where a hostname resolves to something safe when validated and to something internal when used. For anything beyond a single service, a dedicated egress proxy is the stronger control.
The same distrust applies to authorization URLs a server hands a client to open. Clients MUST allow only https (and http for loopback during development), MUST reject schemes such as javascript:, data: and file:, and MUST NOT open URLs by passing them to a shell. A URL handed to cmd.exe or sh is a command injection waiting for the right characters.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
5. The command field is code execution — allowlist it
Back to where this started. For local servers, the specification requires that a client offering one-click setup MUST show the exact command, untruncated, and get explicit approval before running it, and it recommends sandboxing with minimal default privileges.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab) The mitigation the OX research points to for everyone else is allowlist-based validation of the command field for every STDIO server definition, before execution is permitted.1MCP by Design: RCE Across the AI Agent EcosystemResearch note on OX Security's findings; vendor response and mitigations Open source (opens in a new tab)
// The SDK executes whatever the command field contains. Restricting it is the
// integrator's job, so the allowlist lives here: absolute paths, fixed
// arguments, and nothing a remote config or a PATH lookup can substitute.
var allowedServers = map[string]serverSpec{
"filesystem": {Path: "/opt/mcp/bin/mcp-filesystem", Args: []string{"--root", "/srv/shared"}},
"tickets": {Path: "/opt/mcp/bin/mcp-tickets"},
}
func launch(ctx context.Context, name string) (*exec.Cmd, error) {
spec, ok := allowedServers[name]
if !ok {
return nil, fmt.Errorf("mcp server %q is not on the allowlist", name)
}
cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) // no shell involved
cmd.Env = spec.Env // not inherited wholesale
return cmd, nil
}The shape to aim for: the only thing that can come from configuration, a user or another system is a name. Paths and arguments come from code you reviewed. If your agent reads MCP server definitions from a file anyone else can write, from a repository it cloned, or from a registry it queried, that file is an arbitrary code execution vector with extra steps.
6. Ask for the least scope, and step up
The last rule is about blast radius. A token carrying files:* or admin:* because it was granted everything up front turns any leak — through a log, memory, or a compromised hop — into access to everything. The specification's model is progressive: a minimal initial scope, then targeted elevation when a privileged operation is first attempted. It names the common mistakes too: publishing every possible scope in scopes_supported, wildcard scopes, bundling unrelated privileges to avoid a future prompt, and treating a scope claim in the token as sufficient without server-side authorization logic.3Security Best PracticesVersion 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization Open source (opens in a new tab)
In practice, when a request needs more than the token carries, the server answers with a 403 naming exactly the scope required for this operation, all in a single challenge:2AuthorizationVersion 2026-07-28 · Token Handling; Resource Parameter Implementation; Scope Challenge Handling Open source (opens in a new tab)
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"One detail worth copying: the specification says scopes needed for the current operation SHOULD be emitted together, rather than one per retry, so a single action doesn't cost the user several authorization rounds.2AuthorizationVersion 2026-07-28 · Token Handling; Resource Parameter Implementation; Scope Challenge Handling Open source (opens in a new tab) Least privilege that makes users click through five prompts in a row gets turned off.
The checklist
Run this against every MCP server you operate and every client that connects to servers you don't:
- Audience. Tokens are rejected unless they were issued for this server's canonical URI.
- No passthrough. Upstream calls use their own tokens; the caller's token never leaves the process.
- Resource parameter. Clients send
resourceon every authorization and token request. - No connection identity. Nothing about who is calling is inferred from the connection or from self-reported
clientInfo. - Owned handles. State handles are random and keyed by the verified user; the wrong user gets "not found".
- Per-client consent. Proxy servers hold their own consent registry, exact redirect matching, and set
stateonly after approval. - Safe egress. Discovery and metadata fetches can't reach private, loopback or link-local addresses, including via redirects.
- Safe URL handling. Only
httpsis opened, never through a shell. - Allowlisted commands. STDIO servers launch from reviewed paths with fixed arguments; nothing external supplies a command.
- Least scope. Minimal initial scopes, single precise step-up challenges, and authorization checks that don't stop at the scope claim.
None of these is exotic. Most are an afternoon each. They're skipped for the same reason the security section of any specification is skipped: the tutorial worked without them. MCP has made the responsibility explicit — the rules are written down, and the SDK's maintainers have said on the record whose job they are. The only thing left to decide is whether your servers follow them before, or after, somebody checks.
Sources
- MCP by Design: RCE Across the AI Agent Ecosystem (opens in a new tab)
Research note on OX Security's findings; vendor response and mitigations
- Authorization (opens in a new tab)
Version 2026-07-28 · Token Handling; Resource Parameter Implementation; Scope Challenge Handling
- Security Best Practices (opens in a new tab)
Version 2026-07-28 · Token Passthrough; State Handle Hijacking; Confused Deputy Problem; Server-Side Request Forgery; OAuth Authorization URL Validation; Local MCP Server Compromise; Scope Minimization
- Overview (opens in a new tab)
Statelessness; _meta per-request protocol fields
Thinking about an agent like this for your team?
Describe the job you want automated and our Automation Architect blueprints it in seconds — or talk it through with the engineers who ship them.