# MCP Security: OAuth, Tool Permissions, and Prompt Injection

> Secure remote and local MCP servers with OAuth audience binding, least-privilege tools, prompt injection defenses, sandboxing, audit logs, and tests.
> Author: Roman Belov · Published: 2026-08-21 · Source: https://futurecraft.pro/blog/mcp-security-guide/

An MCP server turns model output into capability. A calendar search is modest. Sending
mail, running SQL, changing cloud infrastructure, or reading a home directory is not.
The security question is therefore not “is this prompt safe?” It is:

> If one component is fooled or compromised, what can it read, change, or send?

That framing catches failures OAuth alone cannot. A valid user can still be tricked
into approving the wrong action. A trusted server can return poisoned content. A local
server can be legitimate and still have far too much access.

This guide is the security layer for the broader
[MCP server architecture](/blog/mcp-servers-explained/) and
[production server guide](/blog/mcp-production-custom-servers/). It follows the current
MCP specification dated **July 28, 2026**; version-sensitive links below point to that
revision rather than to an undated summary.

## Draw the trust boundaries first

A typical remote path has four principals:

```text
user → MCP host/client → MCP server → upstream API or database
                         ↘ model context and tool results
```

They do not share one identity or one trust level.

- The **host** decides what context and tools the model can see.
- The **client** speaks MCP and carries authorization for the current caller.
- The **server** validates that caller and enforces tool policy.
- The **upstream system** has its own identity and permission model.
- The **model** proposes actions; it is not an authorization engine.

Write down which boundary checks identity, tenant, object ownership, scope, and user
approval. If a box says “the prompt handles it,” the design has no hard control there.

## Threat model: the failures worth designing for

| Failure | What it can become | Primary control |
| --- | --- | --- |
| Malicious tool description | The model selects a tool or leaks context it should not | Trust registry, review, tool allowlist, isolated contexts |
| Injection in a document or tool result | A read operation steers a later write operation | Treat results as data, separate read and act stages, approve sensitive calls |
| Broad scopes or wildcard tools | One token unlocks unrelated systems | Per-capability scopes and step-up authorization |
| Token passthrough | A token is replayed at the wrong service | Audience binding and separate upstream credentials |
| Unchecked tool arguments | SQL, command, path, URL, or tenant injection | Schema plus semantic validation and server-side policy |
| Guessable workflow handle | Cross-user state access | Opaque expiring handles bound to the authenticated principal |
| OAuth metadata SSRF | Access to internal services or cloud metadata | HTTPS, destination validation, redirect checks, egress policy |
| Over-privileged local server | Source, SSH keys, or credentials become readable | Sandboxing and explicit filesystem/network grants |
| Verbose telemetry | Credentials or customer data persist in logs | Field allowlist, redaction, access control, retention |

This list is intentionally operational. “The model may hallucinate” is not a useful
threat until it is connected to a capability and an impact.

## Remote authorization: implement the resource-server boundary

For HTTP transports, MCP defines an OAuth-based flow. The
[current authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
requires Protected Resource Metadata for authorization-server discovery and Resource
Indicators so the requested token is bound to the intended MCP server.

At the server, every protected request needs deterministic checks:

```text
signature → issuer → audience/resource → expiry → scopes → tenant/object policy
```

The first five validate the credential. The last one validates the requested action.
A token with `tickets:write` does not prove that the caller may update every ticket in
every tenant.

### Keep scopes close to capabilities

Avoid a single `mcp:*` or `admin` scope. Prefer names that match stable permissions:

```text
issues:read
issues:comment
issues:close
deployments:read
deployments:promote
```

Start with discovery and read access. Request a stronger scope when the user first
attempts a privileged operation. The current spec supports this through a `403` plus an
`insufficient_scope` challenge. Its
[scope-minimization guidance](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#scope-minimization)
explains why asking for everything up front increases blast radius and makes consent
less meaningful.

### Never pass the MCP token downstream

Token passthrough is not a shortcut. It erases the boundary between the MCP server and
the API behind it. The MCP authorization spec requires the server to accept only tokens
intended for itself and forbids transmitting other tokens onward.

If the server calls GitHub, Google, or an internal API, use a separate upstream token:

```text
client --token audience:mcp.example--> MCP server
MCP server --different upstream token--> API
```

Store the mapping server-side. Never expose either token to the model, tool output, URL
query string, or routine logs.

### OAuth discovery is also an outbound request surface

A malicious server can put internal addresses in resource or authorization metadata.
Validate every discovery URL and every redirect hop. Production clients should require
HTTPS, reject private, loopback, link-local, and cloud metadata ranges unless a narrow
local exception is explicit, and enforce the same policy after DNS resolution. An
egress proxy is stronger than a hand-written list of string checks. The MCP
[SSRF guidance](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#server-side-request-forgery-ssrf)
covers DNS rebinding and redirect-chain cases that simple URL validation misses.

## Authorization is not tool approval

Authorization answers: **may this principal use this capability?** Approval answers:
**should this exact action run now with these arguments?** You often need both.

A useful default policy is:

| Operation | Default handling |
| --- | --- |
| Read from a narrow, trusted source | Allow after normal authorization |
| Read from the open web, email, or uploaded files | Allow, but mark the result untrusted |
| Create a reversible draft | Show the intended target and important fields |
| Send, publish, charge, delete, deploy, or change access | Require explicit confirmation |
| Execute arbitrary code or query | Deny by default; expose a constrained operation instead |

The MCP tools specification recommends a human able to deny invocations and asks
clients to show tool inputs for sensitive operations. Tool annotations can improve the
UI, but they are not enforcement. The
[tools specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)
requires clients to treat annotations as untrusted unless they come from a trusted
server.

Approval must show the consequence, not an opaque JSON blob. “Send this message to
alex@example.com” is useful. “Run `messages.execute`?” is not. Bind the approval to a
canonical form of the arguments so the target cannot change between preview and
execution.

## Treat all model-facing content as untrusted

Prompt injection often arrives indirectly: a fetched webpage, issue description,
email, source comment, or MCP tool result tells the model to ignore its task and take a
different action. The payload may come from a server you trust operationally because
that server reads data you do not trust.

Do not rely on a stronger system prompt as the only defense. The practical controls
sit around the model:

1. **Separate reading from acting.** A component that summarizes arbitrary web content
   should not also hold deployment or messaging tools.
2. **Pass structured facts forward.** Convert untrusted text into a typed record with
   provenance. Do not forward hidden HTML, instructions, or unnecessary raw content.
3. **Authorize each action server-side.** The model cannot grant itself access by
   writing a persuasive tool argument.
4. **Require approval for consequential actions.** Show destination, mutation, and
   source of the request.
5. **Constrain egress.** If a tool only calls one API, it does not need arbitrary
   internet access.
6. **Validate rendered output.** Strip dangerous HTML and do not auto-fetch model-made
   URLs containing private data.

The [OWASP prompt injection guidance](https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html)
treats least privilege, structured separation, output validation, and human approval
as defense in depth. None is a universal detector. Design for an injection eventually
getting through, then limit what it can accomplish.

## Build narrow tools, not a remote shell

The safest validation is a smaller interface. Compare:

```text
execute_sql(query: string)
```

with:

```text
get_invoice(invoice_id: InvoiceId)
mark_invoice_reviewed(invoice_id: InvoiceId, reason: ReviewReason)
```

The second design gives the server a place to enforce ownership, allowed transitions,
and bounded values. JSON Schema catches shape errors, but it does not answer whether a
path is inside an approved root or an object belongs to the caller.

For every tool, validate:

- types, lengths, formats, enum values, and request size;
- tenant and object ownership from verified identity, never from model claims;
- paths after canonicalization, against an allowed root;
- URLs after parsing and DNS resolution, with an egress policy;
- query parameters through prepared statements or a fixed query builder;
- business transitions and idempotency keys;
- result size, content type, and fields returned to the model.

Use separate read and write tools. Rate-limit by principal and expensive operation. Put
deadlines and cancellation on every upstream call. These controls also make the server
easier to test and operate.

## Local servers need process security, not pretend OAuth

The current MCP authorization spec says stdio implementations should obtain
credentials from the environment rather than use the HTTP authorization flow. That
does not make a local server safe. It runs as a process with whatever privileges the
host gives it.

Before installing a local server:

- inspect the exact command, arguments, package source, and pinned version;
- avoid install commands that execute mutable packages on every launch;
- grant only required directories, preferably read-only;
- block network access unless the tool needs a named destination;
- inject only the secrets that this server uses;
- run risky parsers and code execution in a sandbox or container;
- keep the server on stdio unless another local process genuinely needs HTTP access.

The MCP project’s
[local-server guidance](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#local-mcp-server-compromise)
is blunt: a local server may execute with the client’s privileges. A convenient install
button is therefore a code-execution consent flow, not a harmless configuration edit.

## State handles are names, not credentials

Long workflows often return `workflow_id`, `basket_id`, or another handle for a later
tool call. The July 2026 protocol is stateless at this layer. Possessing a handle must
not be enough to access its state.

Generate opaque, unguessable handles; give them a bounded lifetime; and bind them on
the server to the verified user and tenant. Check that binding on every call. Do not
trust a `user_id` argument supplied beside the handle.

## Audit without building a data leak

A useful audit event answers who attempted what, against which object, under which
policy, and what happened:

```json
{
  "event": "mcp.tool_call",
  "request_id": "req_01...",
  "principal_id": "usr_internal_42",
  "server": "billing-tools",
  "tool": "mark_invoice_reviewed",
  "target_id": "inv_7f...",
  "decision": "allowed",
  "approval_id": "apr_01...",
  "scope_set": ["invoices:review"],
  "result": "success",
  "duration_ms": 184
}
```

Do not log bearer tokens, authorization codes, cookies, secret tool arguments, full
email bodies, or raw model context by default. Prefer stable internal IDs and field
allowlists. Protect audit storage from the service it records, restrict access, set a
retention period, and test deletion where policy requires it.

## Security tests that belong in CI

Happy-path Inspector calls are not a security test. Add cases for:

- missing, expired, wrong-issuer, and wrong-audience tokens;
- valid token with insufficient scope;
- cross-tenant object IDs and stolen state handles;
- extra JSON properties, oversized input, path traversal, and injection strings;
- URLs resolving to loopback, private, link-local, or redirected internal targets;
- tool output containing indirect instructions and exfiltration links;
- approval arguments modified after the preview;
- duplicate writes, retries, timeouts, and cancellation;
- logs and traces containing seeded fake secrets;
- unavailable authorization, policy, audit, and upstream services.

Then run end-to-end agent scenarios. The complete
[MCP server testing guide](/blog/mcp-server-testing/) covers contract snapshots,
protocol-version matrices, transport failure injection, and conformance. Those tests
prove that the server rejects bad requests; they do not prove that the host chooses a
safe tool sequence. The [agent evaluation guide](/blog/ai-agent-testing-evaluation/)
covers trajectory and task-level regression gates.

## Production review checklist

Before exposing a server, confirm:

- [ ] Each trust boundary and data class has an owner.
- [ ] Remote tokens are validated for issuer, audience, expiry, and scope.
- [ ] Downstream APIs use separate credentials; token passthrough is impossible.
- [ ] Tool access is derived from verified identity and policy.
- [ ] Sensitive calls require an argument-bound user approval.
- [ ] Tools expose narrow operations and reject unknown fields.
- [ ] External content and tool results are treated as untrusted data.
- [ ] Local processes have explicit filesystem, network, and secret grants.
- [ ] Handles are opaque, expiring, and bound to the caller.
- [ ] Rate limits, deadlines, output limits, and cancellation exist.
- [ ] Audit events omit secrets and unnecessary personal data.
- [ ] Revocation and emergency disable paths have been exercised.

No item makes an agent “prompt-injection proof.” Together they turn a model failure
from an unrestricted incident into a rejected request, a bounded action, or an approval
the user can stop.
