Contents
Figure 1: Securing an MCP Server: OAuth, Permissions, Least Privilege — authenticate the caller, then assume the caller gets tricked
An MCP server is a very convenient way to give a language model real power: query a database, file a ticket, send an email, merge a pull request. It is also a very convenient way to give an attacker the same power.
The server sits between an AI agent that follows instructions from text it reads and the systems that hold your data. Treat it like any privileged service, and then assume that the model, the user input and the tool descriptions can all be hostile.
This guide covers three pillars of MCP server security: authentication with OAuth, permissions, and least privilege. I researched it in October 2026, and numbered brackets like [1] point to the sources at the end. The specification evolves, so check the current MCP documentation before you implement anything. The code is my own and untested.
Why MCP Servers Attract Attackers
The track record already looks sobering. One tally of publicly disclosed MCP-related incidents counted 16 between April 2025 and April 2026, with at least nine leading to remote code execution, and it noted that most did not require sophisticated exploitation. A few examples from secondary reports:
| Incident | What happened |
|---|---|
| MCP Inspector (CVE-2025-49596) | Unauthenticated instances could be exploited to run arbitrary commands, rated 9.4 critical [5] |
mcp-remote (CVE-2025-6514) |
A popular OAuth proxy trusted authorization URLs from remote servers without validation, allowing command injection; one source reports more than 437,000 downloads [4][5] |
| GitHub MCP server (May 2025) | A malicious issue containing hidden instructions redirected an agent to read private repositories and post the contents publicly [4] |
| Supabase and Cursor (mid-2025) | A privileged agent processing user-submitted tickets was tricked into leaking integration tokens through prompt injection [4][5] |
| Postgres reference server | A "read-only" server fell to SQL injection, as covered in my database tutorial |
The common thread: too much privilege, too little validation, and untrusted text steering a trusting agent. One source puts it bluntly: the privilege level is what made the attack possible [5]. These are secondary summaries, so check the original disclosures for details.
Start With a Threat Model
Ask four questions about your server:
- Who can reach it? Only a local user, your internal network, or the public internet?
- Who is calling? One known user, many users, or other agents?
- What can it do? Read data, write data, spend money, run code?
- What untrusted text flows in? Web pages, emails, tickets, database rows and tool descriptions can all carry instructions.
Your answers decide which controls you need. A local, read-only server for one developer needs far less than a shared, internet-facing server that can modify production systems.
Authentication: What the MCP Spec Says
When OAuth Applies
Authorization in MCP is optional, and the specification defines it for HTTP-based transports [2]. A local server launched as a subprocess over stdio does not use OAuth, so its security rests on the operating system account, the process permissions and the credentials you pass through the environment (my own understanding; confirm it in the transport docs). Remote servers need real authentication.
The Roles
When authorization is used, the MCP server acts as an OAuth 2.1 resource server, and the AI client acts as an OAuth client [2][3]. The server must validate that each access token was issued specifically for it, as the intended audience [3].
The Flow in Plain Language
- The client calls your server without a token.
- Your server replies with 401 and a
WWW-Authenticateheader that points to its metadata[2][3]. - The client fetches your Protected Resource Metadata (RFC 9728), which names the authorization server to use
[2][3]. - The client reads the authorization server's metadata and runs an authorization code flow with PKCE.
- The client includes a resource parameter (RFC 8707) in its requests, so the token binds to your server
[1][2][3]. - The client calls your server with the token, and your server validates it.
A sample 401 header, as shown in one guide [2]:
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
And a minimal metadata document your server might publish (field names follow RFC 9728; check the RFC for the full list):
{
"resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["orders:read", "orders:refund"],
"bearer_methods_supported": ["header"]
}
Rules the Spec Stresses
- Use the resource parameter. Clients must include it in authorization and token requests, so tokens bind to the intended server
[1]. - Require PKCE. If the authorization server's metadata does not advertise PKCE support, clients must refuse to proceed
[1]. - Follow OAuth 2.1 security practices, including secure token storage and secure communications
[1]. - Never pass tokens through. The spec explicitly forbids token passthrough, where a server accepts a token not issued for it and forwards it to another API, because it bypasses audience checks, breaks audit trails and creates confused deputy problems
[1][2][3]. - Consider Client ID Metadata Documents carefully. Authorization servers must weigh their security implications
[1].
Treat that list as a minimum. Read the current authorization and security best practices pages before you build [1].
Validating Tokens on Your Server
Here is a sketch of the checks every request should pass. It uses the PyJWT library, with placeholder URLs:
import jwt
from jwt import PyJWKClient
JWKS = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
RESOURCE = "https://mcp.example.com" # this server's own identifier
ISSUER = "https://auth.example.com"
def validate(token: str, required_scope: str) -> dict:
key = JWKS.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience=RESOURCE, # reject tokens issued for other servers
issuer=ISSUER,
options={"require": ["exp", "aud", "iss"]},
)
scopes = set(claims.get("scope", "").split())
if required_scope not in scopes:
raise PermissionError("insufficient_scope")
return claims
The audience=RESOURCE line does the most important work. It is the check that stops tokens meant for other services from working on yours.
Don't Build the Authorization Server Yourself
Use an established identity provider or a well-reviewed library for the OAuth authorization server. Hand-rolled OAuth is a classic source of subtle bugs. Your MCP server should only validate tokens and enforce permissions.
Permissions: Decide What Each Caller May Do
Authentication says who is calling. Authorization says what they may do. Keep them separate.
Use Scopes Per Tool
Map each tool to the narrowest scope that covers it, and check the scope on every call, not only at connection time:
TOOL_SCOPES = {
"list_orders": "orders:read",
"get_order": "orders:read",
"refund_order": "orders:refund",
}
def authorize(tool_name: str, claims: dict) -> None:
needed = TOOL_SCOPES[tool_name]
if needed not in set(claims.get("scope", "").split()):
raise PermissionError(f"{tool_name} requires {needed}")
Act as the User, Not as a Super-User
A shared service account with broad access turns every user into an administrator. Prefer per-user tokens, so downstream systems enforce each person's real permissions. If your server must call other APIs, use a proper delegation mechanism, like token exchange, instead of forwarding the client's token [1][3].
Tier Actions by Risk
| Risk | Examples | Control |
|---|---|---|
| Read, low | List orders, fetch a record | Scoped access and row limits |
| Write, medium | Create a ticket, update a field | Explicit scopes, plus user confirmation in the client |
| Destructive or financial, high | Refund, delete, merge, deploy | Human approval, a separate role, extra logging — or leave the tool out |
Least Privilege in Practice
Least privilege means every identity gets the minimum access it needs and nothing more. Apply it at each layer:
- Credentials: give the server's downstream accounts only the permissions the tools need. A database server should connect as a read-only role, as my database tutorial shows.
- Data: expose views with only the needed columns, and use row-level security so each user sees only their own data.
- Tools: ship the smallest toolset that does the job. Every extra tool adds attack surface.
- Network: bind development servers to localhost, restrict outbound connections, and put remote servers behind your normal gateway and firewall rules.
- Process: run the server as an unprivileged user, in a container, with a limited filesystem and no spare secrets.
- Time: use short-lived tokens, and rotate credentials.
- Volume: cap rows, request sizes, runtime and calls per session.
The Prompt Injection Problem
Authentication and permissions do not stop an authorized agent from being tricked. Prompt injection ranks first on the OWASP Top 10 for LLM Applications 2025, according to one source, and a researcher quoted there says the field still lacks convincing mitigations [5].
The attacks follow a pattern that people call the lethal trifecta: an agent that can read private data, process untrusted content, and communicate externally [4]. Both the GitHub and Supabase incidents fit it [4].
How to Break the Trifecta
- Remove one leg. If a server reads private data, do not give the same agent the ability to send data out.
- Split servers by trust level. Keep servers that touch sensitive data apart from servers that read untrusted content.
- Treat tool outputs as untrusted input, whether they come from web pages, emails, tickets or database rows.
- Require human approval for actions that move data across a boundary.
- Pin and review tool descriptions. One incident class, tool descriptor injection, hid malicious instructions in tool metadata, and one tally lists a case where a changed descriptor led to code execution. Alert when descriptions change.
- Limit what an injected instruction could do. Least privilege caps the damage.
Harden the Server Itself
Many disclosed incidents were not clever AI attacks. They were ordinary vulnerabilities in the glue code.
- Validate every input. Use parameterised queries, allowlists and strict schemas.
- Never build shell commands from model or user input. Command injection appears throughout the incident lists I read.
- Do not expose unauthenticated development tools. The MCP Inspector case shows what happens
[5]. - Pin and verify dependencies. One tally notes incidents involving unsigned packages. Review third-party proxies and adapters before you trust them with credentials.
- Validate URLs and redirects that come from remote servers, a lesson from the
mcp-remotecase[4]. - Protect secrets. Keep tokens out of logs, repos and error messages.
Log, Monitor, and Prepare to Respond
- Log every tool call with the user identity, tool name, arguments (redacted where needed), result size and outcome.
- Alert on anomalies: unusual volumes, new tools, off-hours access and repeated authorization failures.
- Keep a kill switch: the ability to revoke tokens, disable a tool or shut down the server quickly.
- Write an incident playbook before you need one.
- Review access regularly, and remove unused scopes and accounts.
Common Pitfalls
- Skipping authentication on a "temporary" server. Temporary servers become permanent.
- Forgetting the audience check. Tokens for other services then work on yours.
- Passing the client's token downstream. The spec forbids it
[1][3]. - Using one broad service account. Every user inherits its power.
- Trusting tool output. Text from the outside world can carry instructions.
- Granting write tools by default. Start read-only.
- Running unvetted packages with real credentials.
- Having no logs. You cannot investigate what you cannot see.
Your Security Checklist
Authentication
- Remote servers act as OAuth 2.1 resource servers
- Protected Resource Metadata and the
WWW-Authenticateheader are in place - Tokens are validated for signature, issuer, expiry and audience
- No token passthrough
Permissions
- Scopes map to individual tools and get checked on every call
- Per-user identity flows to downstream systems
- High-risk actions need human approval, or do not exist
Least privilege
- Downstream credentials are read-only or narrowly scoped
- Data exposed through views with row-level controls
- Process runs unprivileged, with limited network and filesystem access
- Row, size and time limits set
Injection and hardening
- Tool outputs treated as untrusted
- The trifecta broken: no private data, untrusted input and outbound access in one agent
- Inputs validated, no shell construction, dependencies pinned
Operations
- Tool calls logged with identity
- Alerts and a kill switch in place
- Regular access reviews
Wrapping Up
Securing an MCP server means treating it as the privileged service it is. Authenticate callers with OAuth, bind tokens to your server's audience, check permissions per tool, and give every credential the minimum access it needs. Then assume prompt injection will happen, and design so that a tricked agent cannot do much damage.
Start with the checklist above on your most powerful server. The best security win in most reviews is deleting a permission nobody needed.
The rest of this series fills in the pieces around it: building a database server, writing the client that sits in front of it, and connecting one to Claude Desktop.
Frequently Asked Questions
Does an MCP server need OAuth?
Only remote, HTTP-based servers need it. Authorization in MCP is optional overall, and a local server launched as a subprocess over stdio does not use OAuth at all: its security rests on the operating system account, the process permissions and the credentials you pass through the environment. Once a server is reachable over a network, treat authentication as mandatory and act as an OAuth 2.1 resource server.
What is token passthrough and why is it forbidden?
Token passthrough is when a server accepts an access token that was not issued for it and forwards that token to another API. The spec forbids it. It bypasses the audience check that binds a token to one service, it breaks the audit trail because downstream calls cannot be attributed to the right issuer, and it creates a confused deputy, where a privileged service is tricked into acting on someone else's authority. Use token exchange or a per-user delegation instead.
Why does the audience claim matter for MCP tokens?
Because it is what stops a token minted for a different service from working on yours. When you validate a token you check the signature, the issuer, the expiry and the audience, and the audience check is the one people forget. In the PyJWT sketch it is the audience argument, set to your server's own resource identifier. Skip it and every token from the same authorization server, issued for any other application, becomes valid here.
How do I stop prompt injection in an MCP server?
You do not stop it with authentication, because an injected instruction arrives through a legitimately authorised session. Instead you limit what a tricked agent could accomplish: break the lethal trifecta so no single agent can read private data, consume untrusted content and communicate externally, treat tool output as untrusted input, require human approval for actions that move data across a boundary, and alert when tool descriptions change. Least privilege is the control that caps the damage.
What is the lethal trifecta?
A pattern describing the conditions for a serious data-theft attack through an agent: access to private data, exposure to untrusted content, and the ability to communicate externally. Any two are usually survivable; all three in one agent is the dangerous combination. Both the GitHub MCP server incident and the Supabase and Cursor incident fit the pattern. The practical fix is to remove one leg, usually outbound communication, or to split the servers across trust levels.
What is least privilege for an MCP server?
Every identity gets the minimum access it needs and nothing more, applied at each layer: the downstream database role, the columns and rows of data exposed, the size of the toolset, the network reach, the process account and container, the lifetime of tokens, and the volume of results. In practice the largest win in a review is usually deleting a permission nobody needed rather than adding a control.
Sources
[1]MCP specification, Authorization: Security considerations — resource indicators, PKCE, token passthrough, the confused deputy: specification/latest/basic/authorization/security-considerations[2]Metorial, "How Does Authorization Work in MCP? OAuth for Agents Explained" — the flow, theWWW-Authenticateexample, optional authorization for HTTP transports: metorial.com[3]Maxim, "MCP server authentication: how to secure agent tool access" — resource server role, RFC 9728, RFC 8707, no token passthrough: getmaxim.ai[4]UpGuard, "Six MCP Security Incidents Every Security Leader Should Know" —mcp-remote, GitHub MCP, Supabase and Cursor, the lethal trifecta: upguard.com[5]TrueFoundry, "MCP Security Risks & Best Practices in 2026" — the MCP Inspector CVE, prompt injection, privilege levels: truefoundry.com