How to Secure MCP Servers: Security Practices for Developers
A practical guide to MCP authentication, authorization, tool poisoning, prompt injection, sandboxing, validation, logging, and incident response.
Secure an MCP server as both an ordinary API and an action surface controlled by an LLM. Authenticate every request, verify that the token was issued for your server, keep upstream credentials separate, minimize tool permissions, validate model-generated arguments and tool results, isolate local execution, and log activity without leaking secrets.
MCP adds a distinct risk: tool descriptions, schemas, arguments, and returned content enter the model context. A malicious instruction can therefore hide in metadata or external data and influence the next action. The controls below combine the MCP project’s requirements with OWASP and Microsoft recommendations. Requirements from the MCP authorization specification are identified as such; OWASP and Microsoft guidance is presented as recommended practice.
1. Define the MCP threat model
A typical deployment contains a host application, an MCP client, one or more MCP servers, tools, and external APIs. Threats cross those boundaries:
| Surface | Typical failure | Primary control |
|---|---|---|
| Remote transport | Stolen, replayed, or mis-scoped bearer token | HTTPS, issuer/resource/audience/expiry/scope checks on every request |
| Authorization | Confused deputy acts with broader rights than the user | Per-user authorization and least-privilege scopes |
| Tool metadata | Tool poisoning or a post-approval “rug pull” changes instructions | Review, pin, hash, and monitor definitions and dependencies |
| Arguments | SSRF, path traversal, command injection, destructive action | Strict schemas, allowlists, bounds, and confirmation gates |
| Tool results | Indirect prompt injection in fetched content | Treat output as untrusted data; sanitize and label it in context |
| Local process | Compromised server reads files or runs commands | Sandbox, restricted filesystem/network, reviewed command, explicit approval |
| Operations | Undetected credential abuse or changed tools | Central logs, redaction, anomaly alerts, and audits |
OWASP catalogs tool poisoning, rug pulls, cross-server shadowing, over-scoped permissions, supply-chain attacks, replay, and sandbox escapes. Read the OWASP MCP Security Cheat Sheet alongside the MCP project’s Security Best Practices.
2. Authenticate and authorize every remote request
Use resource-bound tokens
The MCP Authorization Security Considerations require clients to include the resource parameter in authorization and token requests. The server must validate that the presented token was issued for this MCP server and reject tokens intended for another resource. Validate before parsing or executing a tool call.
At minimum, check:
- signature and trusted issuer;
- audience or resource matches this server;
- expiration, not-before, and clock-skew policy;
- required scopes for the specific tool;
- tenant, user, or subject binding;
- token type and transport (HTTPS only).
Do not pass the inbound MCP bearer token to an upstream API. Obtain a distinct upstream token from that resource’s authorization server, with only the scopes needed for the call. Transport encryption does not replace these checks.
PKCE and redirect rules
MCP clients must use PKCE and use S256 when capable. Verify that the authorization server supports PKCE before proceeding. Authorization endpoints must use HTTPS; redirect URIs must be localhost or HTTPS. Store tokens in an OS credential store or a managed secrets system, never in source code, plaintext configuration, screenshots, or logs. Short-lived access tokens limit damage after theft.
Minimal token validation example (Node.js)
import { jwtVerify, createRemoteJWKSet } from 'jose';
const issuer = 'https://auth.example.com/';
const resource = 'https://mcp.example.com';
const JWKS = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));
export async function authenticate(req) {
const header = req.headers.authorization || '';
const match = header.match(/^Bearer (.+)$/i);
if (!match) throw new Error('missing bearer token');
const { payload } = await jwtVerify(match[1], JWKS, {
issuer,
audience: resource
});
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('expired token');
}
const scopes = String(payload.scope || '').split(' ').filter(Boolean);
if (!scopes.includes('mcp:tools')) throw new Error('missing scope');
return { subject: payload.sub, scopes };
}
Use your identity provider’s documented issuer, key discovery, audience/resource claim, and clock-skew settings. The example illustrates the checks; it is not a replacement for provider-specific validation.
3. Separate user authority from upstream credentials
A confused deputy occurs when the MCP server has a powerful service credential and fails to check what the requesting user is allowed to do. Prefer delegated, per-user access when the upstream system supports it. If a service credential is unavoidable, enforce an application-side policy mapping the authenticated subject and tool arguments to allowed resources.
| Architecture | Strength | Risk to manage |
|---|---|---|
| Per-user delegated token | Best user-level authorization and auditability | Refresh, revocation, and consent lifecycle |
| Service credential | Simpler operations and stable integration | Broad blast radius; requires strict policy and auditing |
4. Design tools for least privilege
Give each server and tool only the permissions required for its job. Split read and write operations, separate tenants, and issue separate credentials per server where practical. A tool that can fetch arbitrary URLs, execute arbitrary shell commands, or write arbitrary paths is difficult to secure.
- Use explicit JSON Schema types, required fields, enums, maximum lengths, and numeric bounds.
- Reject unknown properties unless you have a reason to accept them.
- Use URL allowlists for fetch tools; block private, loopback, link-local, and metadata addresses after DNS resolution.
- Resolve file paths against an approved root and reject traversal, symlinks, and device files.
- Never concatenate model input into a shell command. Prefer fixed executable names and argument arrays; remove shell interpretation.
- Require explicit user confirmation for destructive, financial, permission-changing, or data-sharing actions.
Example: strict URL policy (Python)
from ipaddress import ip_address
from urllib.parse import urlparse
import socket
ALLOWED_HOSTS = {"api.example.com", "docs.example.com"}
def validate_fetch_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme != "https" or parsed.username or parsed.password:
raise ValueError("HTTPS URL without embedded credentials required")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("host is not allowlisted")
for result in socket.getaddrinfo(parsed.hostname, 443, type=socket.SOCK_STREAM):
address = ip_address(result[4][0])
if address.is_private or address.is_loopback or address.is_link_local or address.is_reserved:
raise ValueError("resolved address is not permitted")
return value
# Call this before making the request; also re-check redirects and resolve each hop.
print(validate_fetch_url("https://api.example.com/data"))
Re-check redirects, DNS answers, response size, content type, and timeout. A hostname allowlist alone does not prevent DNS rebinding or an unsafe redirect.
5. Treat descriptions, schemas, and results as untrusted
Review tool names, descriptions, parameter names, defaults, and return schemas as code. A tool description can contain an instruction aimed at the model, and external content returned by a tool can contain indirect prompt injection. Microsoft describes both risks and recommends prompt shields and supply-chain controls; filtering is a defense layer, not a guarantee.
- Pin approved tool definitions and record a hash or version.
- Require review before a definition, schema, dependency, or permission changes.
- Display the target, arguments, and data being shared before sensitive actions.
- Label returned text as data and constrain the model’s next action to the validated result.
- Keep servers isolated so one server cannot silently invoke another’s privileged tools.
Definition-drift check (cURL)
curl --fail --silent --show-error https://mcp.example.com/tools \
-H 'Authorization: Bearer REDACTED' \
-H 'Accept: application/json' \
-o tools.json
sha256sum tools.json
Compare the digest with an approved value in your deployment pipeline. Do not place real bearer tokens in shell history or CI logs.
6. Isolate local MCP servers
For local stdio servers, the process may have the user’s filesystem and network identity. Review the exact command and require explicit approval before launch. Run with a dedicated OS user, read-only mounts where possible, a temporary working directory, restricted environment variables, and an outbound network policy. Sandbox the process and keep sensitive tools separate from general-purpose tools.
The MCP best-practices guidance also covers state handles: possession of a handle is not authentication. Bind each handle to the verified user, use unpredictable random values, and expire them.
# Illustrative Linux launch; adapt to your runtime and sandbox tooling.
./mcp-server \
--config /etc/mcp/readonly.json \
--state-dir /var/lib/mcp-state \
--listen 127.0.0.1:7331
For local HTTP servers, restrict listening to the required interface or require authorization. Treat every local process as potentially compromised if its dependencies or tool package are untrusted.
7. Logging, monitoring, and incident response
Record invocation time, authenticated subject or service identity, server and tool name, schema version, approval decision, latency, outcome, and upstream request identifier. Redact access tokens, cookies, authorization headers, personal data, and sensitive arguments before central storage.
- Alert on new tools, changed hashes, scope expansion, unusual destinations, repeated denials, and sudden error spikes.
- Keep enough metadata to reconstruct who approved a sensitive action.
- Separate security logs from model-visible tool results.
- Rotate credentials and revoke sessions when compromise is suspected.
- Preserve a small, access-controlled audit window for investigation.
8. A deployment checklist
- Identity: HTTPS everywhere; issuer, resource/audience, expiry, subject, and scope checked on every request.
- Credentials: MCP and upstream tokens are distinct; secrets are stored outside plaintext configuration and logs.
- Tools: least privilege, strict schemas, bounded values, reviewed descriptions, pinned definitions.
- Inputs: URL allowlists, SSRF defenses, path confinement, no raw shell execution.
- Outputs: treated as untrusted data, sanitized before model context, size and content limits enforced.
- Actions: explicit confirmation for destructive, financial, permission, and data-sharing operations.
- Local runtime: sandboxed process, restricted filesystem/network, reviewed command and dependencies.
- Operations: redacted invocation logs, anomaly alerts, definition-drift monitoring, tested revocation.
9. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| 401: token rejected | Issuer, signature, expiry, or resource/audience mismatch | Inspect claims and JWKS configuration; request a token for this MCP resource. |
| 403: tool denied | Missing scope or per-user policy denial | Grant the narrow required scope or change the policy; do not use a broader token. |
| Upstream API returns unauthorized | Inbound MCP token was forwarded upstream | Exchange or obtain a separate upstream credential. |
| PKCE authorization fails | Verifier method unsupported or redirect URI mismatch | Use S256 when supported, verify capability, and use localhost or HTTPS redirects. |
| Fetch tool reaches internal host | No post-resolution IP or redirect validation | Resolve every hop, block private/link-local ranges, and enforce an HTTPS host allowlist. |
| Tool behavior changed after approval | Definition rug pull or dependency update | Compare pinned hashes, review the diff, and roll back the changed package or server. |
| Handle works for another user | Handle treated as authentication | Bind it to subject and server session; make it random and expiring. |
| Secrets appear in logs | Raw headers or arguments logged | Redact before serialization, rotate exposed credentials, and restrict log access. |
10. Performance, reliability, and cost
Security checks add predictable work: token verification, policy evaluation, DNS/IP validation, schema validation, and audit logging. Cache issuer keys within their rotation guidance, keep policy evaluation local where possible, and set bounded upstream timeouts. Do not cache authorization decisions longer than the token or policy allows.
Reliability improves when tools are small and independently deployable: a failure in a low-risk server should not block privileged tools. Use circuit breakers for upstream APIs, idempotency keys for retries, bounded response sizes, and explicit cancellation. Measure authentication latency, validation failures, upstream latency, timeout rate, and denied actions. The reviewed sources provide no quantitative benchmark; choose limits from your traffic and upstream contracts.
Cost is driven mainly by authorization infrastructure, logging and retention, sandboxing, and upstream calls. Redaction and sampling can reduce storage while retaining security events. Avoid paying for broad service credentials by narrowing scopes and using short-lived tokens.
11. Using ScreenshotNeo from an MCP workflow
If an MCP agent needs website screenshots, ScreenshotNeo provides an MCP server with take_screenshot, get_page_info, and capture_pdf tools. Apply the same controls: approve the server definition, constrain target URLs, keep its credential separate from the client’s bearer token, and log tool calls without storing secrets.
Or skip the browser setup
For a direct capture, call ScreenshotNeo’s API (see the ScreenshotNeo API docs):
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The Free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Is HTTPS enough to secure an MCP server?
No. HTTPS protects transport. You still need token validation, resource binding, scopes, per-user authorization, and safe tool design.
Should every tool call require a human confirmation?
Require confirmation for destructive, financial, permission-changing, or data-sharing actions. Read-only, low-risk calls can use policy-based approval with strong validation and logging.
Can I reuse the MCP bearer token for an upstream API?
No. The MCP authorization guidance says the upstream call needs a distinct token intended for that upstream resource.
How do I handle prompt injection in fetched pages?
Assume fetched text is hostile data. Limit the destination, sanitize and label the result, constrain the next tool choice, and require confirmation for sensitive actions. Prompt filtering can help but is not a complete control.
Are local servers automatically safer than remote servers?
No. Local servers may have broad filesystem and network access. Sandbox them, restrict permissions, review dependencies, and make launch and sensitive commands visible to the user.
Primary references: MCP Security Best Practices, MCP Authorization Security Considerations, OWASP MCP Security Cheat Sheet, and Microsoft’s guidance on indirect prompt injection in MCP.


