AI Reliability Engineering

Security Patterns for Production MCP Servers

A production MCP server must derive identity from a validated, audience-bound token and apply tenant, resource, and action policy before every tool call.

Treat tool descriptions and arguments as untrusted model input, contain outbound access and side effects, and preserve an audit trail that explains each authorization decision.

By ProfitLabs AI Expert Team20 minute readUpdated July 19, 2026

Start with consequence

An MCP server is a privileged application boundary

Model Context Protocol makes tools discoverable and callable through a common interface. That convenience changes the attack surface. A client can combine natural-language instructions, remote content, tool descriptions, model behavior, and authenticated actions in one flow. A server that exposes customer data or mutation tools is not merely an adapter. It is a resource server and policy-enforcement point.

Threat-model the complete route: user to client, client to authorization server, client to MCP server, MCP server to downstream API, and response back into model context. Include token theft, token substitution, cross-tenant object access, confused-deputy behavior, prompt injection, malicious tool arguments, server-side request forgery, session hijacking, replay, duplicate side effects, oversized payloads, log leakage, and compromised downstream services.

Classify tools by consequence. Read-only metadata, sensitive reads, reversible writes, irreversible writes, administrative actions, and outbound communication need different controls. The model does not lower the required assurance. A tool that sends a message or deletes an object needs the same authorization, validation, audit, and idempotency as a normal application endpoint, plus defenses for untrusted model-generated input.

Tool risk classes
ClassExampleMinimum control
Public readRead public documentationInput bounds, rate limit, source allowlist
Tenant readFetch a customer projectAudience-bound identity and resource authorization
Reversible writeCreate a draft issueExplicit scope, idempotency, audit, confirmation policy
Irreversible writeDelete or publishStep-up approval, narrow tool, rollback or recovery plan
Outbound actionSend email or webhookDestination allowlist, content policy, duplicate prevention
AdministrationChange roles or credentialsPrefer human-only path; isolate from general clients

Transport authorization

Validate the token for this MCP resource

For HTTP-based remote servers, implement the current MCP authorization profile: protected-resource metadata, authorization-server discovery, OAuth security controls, resource indicators, and access tokens carried in the authorization header. The server acts as the resource server. It validates signature, issuer, expiry, audience, and required claims before creating an application identity.

Audience binding is essential. A valid token issued for another API is not valid for the MCP server. Reject tokens that do not identify the canonical MCP resource. Never accept an upstream API token from the client and pass it through to another service. If the MCP server calls a downstream API, it uses a separate credential or token issued for that downstream resource. This prevents token substitution and confused-deputy paths.

Build trusted identity from verified claimstypescript
type AuthContext = {
  subject: string;
  tenantId: string;
  audience: string;
  scopes: ReadonlySet<string>;
  tokenId: string;
  expiresAt: number;
};

async function authenticate(request: Request): Promise<AuthContext> {
  const bearerToken = readBearerToken(request.headers);
  const claims = await verifyAccessToken(bearerToken, {
    issuer: AUTHORIZATION_SERVER,
    audience: MCP_RESOURCE_URI,
  });

  if (!claims.subject || !claims.tenant_id) {
    throw new HttpError(401, "missing_identity_claims");
  }

  return {
    subject: claims.subject,
    tenantId: claims.tenant_id,
    audience: claims.audience,
    scopes: new Set(claims.scope.split(" ")),
    tokenId: claims.token_id,
    expiresAt: claims.expires_at,
  };
}

The authorization server establishes who the subject is and which coarse scopes were granted. The MCP server still decides whether that subject can perform this action on this tenant resource now. A scope such as projects.read should not authorize every project. Combine it with tenant membership, object ownership, role, resource state, and product policy at execution time.

Keep tokens out of URLs, tool arguments, logs, model context, and error messages. Use short-lived access tokens and secure refresh-token handling in clients. Return 401 for missing, invalid, or expired authentication and 403 for an authenticated subject lacking scope or permission. Avoid error detail that reveals another tenant resource exists.

For authorization guidance and required discovery behavior, keep the implementation aligned with the official MCP authorization specification and its security best practices. Pin the protocol version supported by the server and test client compatibility against it.

Application policy

Derive tenant scope from trusted identity, never tool input

The most dangerous multi-tenant design accepts tenantId, organizationId, workspaceId, or accountId as an ordinary tool argument and uses it to select data. The model controls tool arguments. Retrieved content can influence the model. An attacker can ask it to substitute another identifier. Tenant identity must come from the authenticated context and be mandatory in every query and downstream request.

Object lookup constrained by identity and membershiptypescript
async function getProject(auth: AuthContext, projectId: string) {
  return database.project.findFirst({
    where: {
      id: projectId,
      tenantId: auth.tenantId,
      memberships: {
        some: {
          userId: auth.subject,
          permissions: { has: "project.read" },
        },
      },
    },
  });
}

async function requireProject(auth: AuthContext, projectId: string) {
  const project = await getProject(auth, projectId);
  if (!project) {
    throw new HttpError(404, "project_not_found");
  }
  return project;
}

Prefer queries that combine object identifier, tenant identifier, and membership in one database operation. Loading by object identifier and checking tenant later creates opportunities for forgotten checks, timing differences, and unsafe intermediate logging. Return a consistent not-found response when the caller cannot see the object. Add database row-level policy as defense in depth when the stack supports it, while retaining application authorization for business rules.

Carry the trusted tenant through caches, queues, traces, object storage keys, vector queries, and downstream API credentials. Cache keys must include tenant and authorization-relevant state. Background jobs need a signed or otherwise integrity-protected execution context, not a tenant field copied from model output. Trace views and replay tools must enforce the same boundary because tool inputs and results often contain sensitive data.

Test negative paths as first-class behavior: another tenant identifier, a valid object with no membership, a removed user, an expired session, an object moved between workspaces, and a replayed job. Run tests at both protocol and handler levels. A unit test for the query helper does not prove every tool uses it.

Least capability

Make tools narrow enough to authorize and review

A broad execute or request tool turns policy into prompt wording. Prefer purpose-specific tools such as issue.create_draft, invoice.read_summary, or deployment.request_rollback. Narrow tools reduce ambiguous arguments, make scopes meaningful, simplify audit review, and let the server apply domain validation. Separate read from write and draft from publish.

Tool descriptions are part of model context and can influence selection. Write clear capability and consequence statements, but never rely on the description as enforcement. A malicious or mistaken client can call the tool directly. Every handler performs authentication, scope checks, object authorization, input validation, business-rule validation, and output filtering.

Authorize immediately before executiontypescript
type ToolPolicy = {
  requiredScopes: readonly string[];
  authorize: (auth: AuthContext, input: unknown) => Promise<boolean>;
};

async function executeTool(
  auth: AuthContext,
  policy: ToolPolicy,
  rawInput: unknown,
  handler: (input: unknown, auth: AuthContext) => Promise<unknown>,
) {
  for (const scope of policy.requiredScopes) {
    if (!auth.scopes.has(scope)) {
      throw new HttpError(403, "insufficient_scope");
    }
  }

  const input = validateToolInput(rawInput);
  if (!(await policy.authorize(auth, input))) {
    throw new HttpError(403, "resource_forbidden");
  }

  return handler(input, auth);
}

Validate inputs with a closed schema. Reject unknown fields, oversized strings, invalid identifiers, unsupported enum values, dangerous markup, and conflicting options. Normalize only when meaning remains unambiguous. Do not silently turn a malformed request into a broader action. Use bounded pagination and result sizes to prevent context flooding and resource exhaustion.

Treat output as sensitive data. Return the minimum fields needed for the task. Strip internal identifiers, credentials, hidden policy fields, stack traces, and unrelated customer records. A model may repeat anything it sees, so output minimization is a security control. Mark untrusted external text as data and preserve its provenance for the client.

Handle side effects deliberately

Reversible and irreversible actions need idempotency keys tied to authenticated subject, tenant, tool, and intent. Store the result so retries return the original outcome. For high-impact changes, use prepare and commit tools: prepare validates and returns a human-readable summary plus a short-lived operation identifier; commit requires that identifier and rechecks current authorization and state. Never place the full action payload in a user-editable confirmation token.

Untrusted context

Assume instructions can arrive through every resource and tool result

Prompt injection is not limited to the user message. Documents, issue descriptions, web pages, tool output, resource metadata, and stored memory can contain instructions designed to alter model behavior. The MCP server cannot control how every client model interprets context, but it can prevent that text from becoming authority.

Keep authorization and business policy outside model context. Never let a retrieved instruction increase scope, select a tenant, choose a credential, disable validation, or approve an action. Label content provenance and trust level. Separate external text from server-authored instructions in structured output. Use allowlisted tools and destinations. Require confirmation or human approval based on consequence, not model confidence.

Test indirect injection with realistic payloads: a document that asks the model to call an export tool, a tool result that requests another tenant, an issue body containing encoded instructions, and multi-turn attempts that slowly change the goal. Include attempts to reveal tool schemas, server prompts, prior results, and sensitive fields. Preserve transcripts with token and data redaction.

Rate-limit by subject, tenant, client, tool, and consequence. A low-volume read tool and a message-sending tool should not share limits. Add circuit breakers for repeated denied calls, unusual tool sequences, large result volume, and sudden destination changes. The response to suspicious behavior can require reauthorization, reduce available tools, or suspend the session.

Downstream safety

Constrain every destination and credential boundary

Tools that fetch URLs, clone repositories, call webhooks, or connect to customer systems can create server-side request forgery and credential exposure. Parse URLs with a standard parser, allow only expected schemes and hosts, resolve and reject private or reserved addresses, block unsafe redirects, set time and size limits, and isolate network egress where possible.

A bounded outbound requesttypescript
const ALLOWED_HOSTS = new Set([
  "api.github.com",
  "status.example.com",
]);

async function safeFetch(rawUrl: string, init: RequestInit) {
  const url = new URL(rawUrl);

  if (url.protocol !== "https:" || !ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error("outbound_destination_denied");
  }

  const addresses = await resolvePublicAddresses(url.hostname);
  if (addresses.length === 0) {
    throw new Error("destination_not_public");
  }

  return fetch(url, {
    ...init,
    redirect: "error",
    signal: AbortSignal.timeout(5000),
  });
}

DNS can change between validation and connection. Production defenses should validate the actual destination at connection time or route through an egress proxy that enforces policy. Deny metadata endpoints, loopback, link-local, private networks, and unix-style or alternate schemes unless a tool explicitly requires a separate protected route. Do not return raw downstream errors that include headers, tokens, or internal addresses.

Use a distinct downstream credential for the MCP server or user delegation path, scoped to the smallest required resource and action. Never forward the client token. Store secrets outside model context and inject them only at the final trusted connector. Rotate credentials, log their identifier rather than value, and make revocation part of incident response.

For user-supplied destinations, prefer a registration step outside the agent flow. Verify ownership, define allowed operations, and issue an internal destination identifier. The tool accepts that identifier instead of an arbitrary URL. This changes an open network primitive into an application resource that normal authorization can protect.

Explain every action

Record authorization decisions without logging secrets

An audit event should answer who, for which tenant, through which client and session, called which tool, against which resource, with what policy decision, and what happened. Include request and trace identifiers, server and tool versions, token identifier, scope set, idempotency key, result class, duration, and timestamp. Preserve the reason for denial so operators can distinguish attack traffic from a broken client.

A structured tool audit eventtypescript
type ToolAuditEvent = {
  requestId: string;
  sessionId: string;
  subject: string;
  tenantId: string;
  toolName: string;
  inputDigest: string;
  decision: "allowed" | "denied";
  policyReason: string;
  resultClass: "success" | "client_error" | "server_error";
  durationMs: number;
  occurredAt: string;
};

function auditToolCall(event: ToolAuditEvent) {
  auditSink.write({
    ...event,
    subject: stableAuditIdentifier(event.subject),
  });
}

Do not log bearer tokens, refresh tokens, raw credentials, complete prompts, or unrestricted tool results. Hash or tokenize identifiers when operators do not need the original value. Classify tool inputs and outputs and use field-level redaction before the event reaches a general logging pipeline. Restrict access to audit and trace systems by tenant and role, and define retention and deletion rules.

Connect protocol events to downstream actions. A message-sending tool should share a correlation identifier with the downstream API request and resulting object. This allows an incident investigator to prove whether an action was attempted, authorized, retried, completed, or rolled back. Audit failure for high-impact tools should fail closed or route to a durable local buffer with strict bounds; silent loss removes the evidence needed to operate safely.

Build operational views for denied calls, scope mismatches, cross-tenant attempts, unusual tool sequences, error rates, latency, result volume, and idempotency conflicts. Alert on behavior that represents consequence, not every malformed request. Pair alerts with a runbook that can revoke sessions, disable a tool, rotate a credential, and preserve evidence.

Blast-radius control

Expect retries, partial failure, and compromised dependencies

Set deadlines and cancellation behavior for every downstream call. Bound concurrency per tenant and tool. Use queues for durable work and expose operation status rather than keeping a protocol request open through a long workflow. Define what happens when the client disconnects after the side effect begins. Idempotency and durable state should make retry behavior explicit.

Separate tool failures into invalid request, unauthorized, forbidden, conflict, rate limited, dependency unavailable, timeout, and internal failure. Return safe, structured errors the client can handle without exposing internals. Do not encourage the model to retry a forbidden action with different arguments. Attach retryability and a bounded retry-after signal where appropriate.

Create per-tool kill switches and per-tenant suspension. If a downstream provider is compromised or a tool shows abnormal behavior, operators should disable that capability without taking every read tool offline. Keep the default tool set minimal and require explicit enablement for higher-consequence tools. Version capabilities so clients can adapt without calling removed or changed actions.

Back up the policy and audit data needed for recovery, and test restoration. For reversible writes, expose a compensating operation owned by trusted application code. For irreversible external actions, make approval and preview stronger because rollback may be impossible. Document residual risk per tool and review it when the tool or downstream API changes.

Evidence in CI

Test the security boundary at protocol and product levels

Unit-test token validation, scope evaluation, schema validation, and query policy. Integration-test real protocol calls through authentication, tool dispatch, database policy, downstream connectors, and audit output. Adversarial tests should attempt token substitution, wrong audience, expired token, missing tenant, cross-tenant object access, over-broad scope, unknown fields, oversized output, duplicate side effects, unsafe URLs, indirect injection, and session replay.

Negative tests for identity boundariestypescript
describe("tenant-scoped tools", () => {
  it("does not accept tenant identity from tool arguments", async () => {
    const auth = fixtureAuth({ tenantId: "tenant-a" });
    const result = await callTool(auth, "project.read", {
      tenantId: "tenant-b",
      projectId: "project-b",
    });

    expect(result.error).toBe("project_not_found");
    expect(auditSink.last().tenantId).toBe("tenant-a");
  });

  it("rejects a token issued for another resource", async () => {
    const token = fixtureToken({ audience: "https://api.example.com" });
    const response = await requestMcp({ token });
    expect(response.status).toBe(401);
  });
});

Run the official inspector for protocol compatibility, but do not treat protocol conformance as authorization proof. Add product-specific security tests for every tool. Generate a tool inventory with owner, risk class, scopes, resources, side effects, downstream systems, data classes, rate limits, and approval policy. Fail CI when a new tool lacks required metadata or negative tests.

Review threat models and permissions after new tools, new downstream systems, authorization changes, or incidents. Exercise token revocation, tool shutdown, credential rotation, and audit investigation. Measure time to identify the affected tenant and actions. A secure design needs an operating response when a client, token, server, or downstream dependency is compromised.

Before exposure

Production MCP security checklist

  • Publish protected-resource metadata for the canonical server URI
  • Validate signature, issuer, expiry, audience, and required identity claims
  • Never pass the MCP client token to a downstream API
  • Derive tenant identity from authenticated context
  • Authorize subject, tenant, resource, and action before execution
  • Use narrow tools with closed input and minimal output schemas
  • Add idempotency and approval based on consequence
  • Constrain outbound hosts, addresses, redirects, time, and size
  • Redact tokens and sensitive payloads before logs and traces
  • Correlate tool decisions with downstream side effects
  • Test wrong-audience and cross-tenant paths through the protocol
  • Provide kill switches, revocation, rotation, and incident runbooks

MCP security becomes manageable when the protocol boundary is treated like any other privileged production API, then strengthened for model-controlled inputs and tool composition. Identity is verified once per request, authorization is evaluated at every action, and no natural-language instruction can manufacture authority.

Next step

Bring the failure, the evidence, and the system you have today.

We identify the highest-leverage scope, the artifact your team should own, and whether a focused implementation is the right move.

Book a 30-minute consultation

30 minutes. No deck. Leave with a clear next step.