MCP Server Security: OAuth 2.1, Authentication, and Authorization Patterns
MCP servers inherit the full permissions of their host process, which makes remote deployments a different threat model entirely. Here's how the spec handles auth with OAuth 2.1 and what authorization patterns actually work in practice.
MCP servers run with the full permissions of the host process. A tool that reads files can read any file. A tool that runs shell commands can run anything. For local servers this is fine: it’s your machine, your user account. For remote or shared servers, the security model changes completely and the defaults are dangerous.
I’ve been running four MCP servers locally for months without thinking much about security. They’re stdio servers, they run as my user, and their attack surface is exactly zero beyond what Claude Code already has access to. But when I started thinking about remote deployment (exposing an MCP server over HTTP for a team or a multi-tenant setup), the threat model shifted in ways that deserve a dedicated writeup.
The threat model
Three things can go wrong with an MCP server, and they map to the same categories you’d worry about with any API that has access to sensitive systems.
Unauthorized tool invocation. Someone discovers your MCP server endpoint and starts calling tools. If your server has a query_database tool and no auth, anyone who can reach the endpoint can query your database. This isn’t theoretical: MCP servers that expose HTTP endpoints are discoverable the same way any API is.
Data exfiltration via prompt injection. A malicious prompt tricks the AI agent into calling a tool that sends data somewhere it shouldn’t go. The agent thinks it’s being helpful. The tool dutifully executes. Your data ends up in an attacker-controlled server. This is the most insidious attack vector because the tool invocation looks legitimate, but the intent is compromised.
Overly broad tool permissions. A “run SQL” tool that allows DROP TABLE. A file-reading tool that can access /etc/shadow. A shell tool with no command allowlist. The tool does exactly what it’s told, and what it’s told is too powerful for the context it runs in. This is a design problem, not a runtime exploit, but the impact is the same.
All three categories share a root cause: MCP servers are powerful by default and restricted only by what you explicitly limit. The protocol doesn’t enforce any security policy. That’s your job.
Local server security
For local stdio servers, the security model is simple: the trust boundary is your machine.
Your MCP server runs as your user. Claude Desktop or Claude Code already has full access to your files, your shell, your environment. Adding a local MCP server that reads files or runs commands doesn’t expand the attack surface because the client could already do those things directly.
The transport matters here. Stdio servers communicate over standard input/output with the local process. There’s no network socket. There’s no port to discover. The only entity that can talk to your server is the AI client that spawned it. You don’t need auth because there’s no way for an unauthorized party to reach the server in the first place.
This is why my servers (scry, tome, lore, flume) don’t implement any authentication. They’re local tools for a local workflow. Adding OAuth to a stdio server would be security theater.
The exception is if your local server reaches out to remote systems. A local MCP server that proxies requests to a production database should authenticate with that database using scoped credentials, regardless of whether the MCP transport itself is authenticated. The trust boundary question is always about what the server can reach, not just how the client reaches the server.
OAuth 2.1 in the MCP spec
When you move to HTTP transport (remote servers, shared infrastructure, multi-tenant setups), the MCP spec prescribes OAuth 2.1 as the authentication framework. This isn’t a suggestion. The spec defines the exact flow.
Authorization code flow with PKCE. The client initiates auth by redirecting the user to the server’s authorization endpoint. PKCE (Proof Key for Code Exchange) prevents authorization code interception, which matters because MCP clients are public clients that can’t securely store a client secret. The flow produces an access token scoped to the permissions the user granted.
Token exchange. The client includes the access token in subsequent requests. The server validates the token, extracts the user identity and scopes, and applies authorization decisions based on what that user is allowed to do. Refresh tokens keep sessions alive without re-prompting.
Auth challenges. When a client hits a protected endpoint without a valid token, the server responds with HTTP 401 and a WWW-Authenticate header pointing to the authorization endpoint. The client handles the challenge automatically: the user sees a consent screen, grants access, and the client retries with the new token.
In Go, the official mcp-go SDK provides auth middleware that slots into this flow. You configure your OAuth provider (any OIDC-compliant provider works), define your scopes, and the middleware handles token validation on every request. Your tool handlers receive an authenticated context with the user’s identity and granted scopes already extracted.
The implementation looks like any other OAuth-protected API. The difference is that your “API consumer” is an AI agent acting on behalf of a user, which means the consent model needs to be clear about what the agent will do with the granted permissions.
Authorization patterns
Authentication tells you who’s calling. Authorization tells you what they’re allowed to do. MCP doesn’t prescribe an authorization model. That’s intentional, because the right model depends entirely on your use case.
Tool-level permissions. Not every authenticated user should access every tool. A read-only analyst shouldn’t invoke the execute_migration tool. Implement this as a middleware check: before a tool handler runs, verify the user’s role or scopes include permission for that specific tool. In Go, this is a wrapper function around your tool handlers that checks claims from the JWT.
Read-only vs read-write separation. Split your tools into tiers. Read tools (query data, list resources, describe schemas) get one scope. Write tools (create records, modify state, run commands) get a more restrictive scope. This maps naturally to OAuth scopes: tools:read and tools:write. A user granted only tools:read literally cannot invoke write operations.
Resource-scoped tools. A database query tool that can access any table in any schema is dangerous. Scope it: this user can query analytics.* but not users.*. This user can read from production but not write. The tool still exposes a query parameter, but the handler enforces boundaries based on who’s calling. The ergonomics are the same for the agent, which calls query_database with SQL, but the server constrains what SQL is allowed to execute.
Tenant isolation. For multi-tenant deployments, every tool invocation must be scoped to the caller’s tenant. The tool handler extracts the tenant from the auth context and applies it as an implicit filter. The agent never sees data from other tenants because the server makes it impossible.
Input validation
The MCP protocol defines JSON Schema for tool parameters. The SDK validates that incoming calls match the schema: correct types, required fields present, enum values respected. This is not sufficient for security.
Schema validation tells you the input is structurally correct. It doesn’t tell you the input is safe. A query parameter defined as type: string will happily accept DROP TABLE users; --. A file_path parameter will accept ../../../../etc/passwd.
Treat every tool call as untrusted input. The same validation rules you’d apply to a REST API apply here:
- SQL parameters should use parameterized queries, never string interpolation. If your tool builds SQL from user-provided values, you have a SQL injection vulnerability regardless of the schema.
- File paths should be resolved and checked against an allowlist of base directories. Reject anything with
..segments or absolute paths outside the allowed root. - Shell commands (if you expose them at all) should use an explicit allowlist. Never pass agent-provided strings directly to
exec. - URLs should be validated against an allowlist of domains. A tool that fetches URLs can be weaponized for SSRF if it accepts arbitrary destinations.
The fact that an AI agent is the caller doesn’t make the input trustworthy. The agent is executing prompts, and prompts can be adversarial. A prompt injection attack works by convincing the agent to call tools with malicious parameters. Your server is the last line of defense.
Practical advice
Here’s what I’d tell someone shipping their first MCP server:
Local servers: don’t overthink it. If it’s stdio, if it’s your machine, if the only consumer is your local AI client, skip auth, skip input validation beyond basic sanity checks, ship it. The security model is your operating system’s user permissions and that’s fine.
Remote servers: OAuth is not optional. The moment your server is reachable over a network, unauthenticated access is unacceptable. Use the spec’s OAuth 2.1 flow. Use an established provider (Auth0, Clerk, your company’s IdP). Don’t roll your own token validation.
Validate inputs regardless. Even behind auth, tool parameters are untrusted. Parameterize queries. Validate paths. Allowlist commands. This takes an hour to implement and prevents the entire class of injection attacks.
Scope tool permissions. Fewer tools with narrow scope beats many tools with broad access. A read_table tool that accepts a table name from an allowlist is safer than a run_sql tool that accepts arbitrary queries. Design your tool surface area like you’d design an API, following the principle of least privilege.
Log everything. Every tool invocation, every parameter, every user identity. When something goes wrong (and it will), you need the audit trail. Structured logging with the tool name, caller identity, parameters, and result status. This is also how you detect abuse patterns.
The minimum viable security stack for a remote MCP server: OAuth 2.1 with PKCE, tool-level authorization checks, parameterized queries for any data access, input validation on all parameters, and structured audit logging. That’s five things. None of them are novel. All of them are required.
This post is part of a series on building and deploying MCP servers. See the complete guide to MCP servers for the protocol overview, building MCP servers in Go for implementation details, and deploying MCP servers for infrastructure patterns.
Frequently Asked Questions
How do you secure an MCP server?
For local stdio servers, ambient user permissions are sufficient: the server runs as your user and has the same access your AI client already has. For remote servers, you need OAuth 2.1 with PKCE for authentication, tool-level authorization to control which users can call which tools, input validation on all tool parameters, and comprehensive logging. The MCP spec provides the auth framework; you provide the authorization logic.
Does MCP support authentication?
Yes. The MCP specification includes OAuth 2.1 as its authentication framework. Remote servers can require authorization code flow with PKCE, issue scoped access tokens, and challenge unauthenticated requests with standard HTTP 401 responses. The official Go SDK includes auth middleware that handles token validation and refresh.
What are the security risks of MCP servers?
The main risks are unauthorized tool invocation (someone calling your tools without authentication), data exfiltration (a malicious prompt tricking the agent into sending data to an external endpoint), overly broad tool permissions (a SQL tool that allows arbitrary writes), and injection attacks through tool parameters (SQL injection, path traversal). Remote servers without auth are essentially open APIs callable by anyone.
Is OAuth required for MCP servers?
Not for local stdio servers, which communicate over standard input/output with the local AI client and inherit your user permissions. But for any server exposed over HTTP (remote or shared servers), OAuth 2.1 is the authentication mechanism defined in the MCP spec and should be treated as mandatory. Running a remote MCP server without auth is equivalent to running an unauthenticated API with shell access.