---
title: "Securing MCP Servers: Authentication, Authorization, and Access Control"
description: "Secure your MCP servers with OAuth, API keys, RBAC, and endpoint scoping. Practical patterns for authenticating AI agents and controlling access to MCP tools."
canonicalUrl: "https://zuplo.com/learning-center/securing-mcp-servers-auth"
pageType: "learning-center"
authors: "nate"
tags: "Model Context Protocol, API Security"
image: "https://zuplo.com/og?text=Securing%20MCP%20Servers%3A%20Authentication%2C%20Authorization%2C%20and%20Access%20Control"
---
MCP servers expose your APIs to AI agents as callable tools. Without proper
security, any agent could access any endpoint — read sensitive data, trigger
mutations, or exhaust your resources. The Model Context Protocol provides a
powerful abstraction for tool discovery and invocation, but it says nothing
about who is allowed to call those tools or under what conditions.

That is your problem to solve. This guide covers practical patterns for
authenticating AI agents, authorizing their access to specific tools, rate
limiting their usage, and logging every invocation for audit and compliance. If
you have already built an MCP server — whether from scratch or by
[creating one from your OpenAPI spec](/learning-center/create-mcp-server-from-openapi)
-- this is the next step to making it production-ready.

## The MCP Security Challenge

MCP was originally designed for local tool use. An AI assistant running on your
machine could call local tools — read a file, query a database, run a shell
command — without worrying about network security. The tool and the agent shared
the same trust boundary.

That model breaks down the moment you deploy an MCP server remotely. A remote
MCP server is, architecturally, just an API. It accepts requests over HTTP, does
work, and returns results. That means it faces the same security concerns as any
other API:

- **Authentication**: Who is making this request? Is the caller who they claim
  to be?
- **Authorization**: Is this caller allowed to invoke this specific tool with
  these specific parameters?
- **Rate limiting**: How many requests can this caller make per minute, per
  hour, per day?
- **Audit logging**: What did this caller do, when, and what was the result?

The difference is that your callers are not humans clicking buttons in a
browser. They are AI agents making automated decisions about which tools to call
and with what parameters. Agents can be persistent, fast, and unpredictable. A
misconfigured agent might hammer your endpoints in a tight loop. A compromised
agent might exfiltrate data through tool calls that look legitimate in isolation
but form a pattern of abuse.

You need security controls that account for these behaviors.

## Authentication Options for MCP Servers

Authentication answers the question: who is calling this MCP tool? You have
several options depending on your deployment model and security requirements.

### API Keys

API keys are the simplest authentication mechanism. Each agent (or agent
operator) gets a unique key that must be included in every request. Keys are
easy to issue, easy to revoke, and easy to understand.

API keys work well for:

- Server-to-server communication where both sides are trusted
- Internal deployments where agents run in your own infrastructure
- Development and testing environments
- Simple integrations where OAuth would be overkill

With Zuplo, you can add API key authentication to your MCP server without
writing any code. Add the API key authentication policy to your routes and Zuplo
handles validation, key management, and consumer identification automatically.

Here is what a Zuplo route configuration looks like with API key auth enabled:

```json
{
  "paths": {
    "/mcp": {
      "post": {
        "x-zuplo-route": {
          "handler": {
            "export": "mcpServerHandler",
            "module": "$import(@zuplo/mcp)"
          },
          "policies": {
            "inbound": ["api-key-inbound"]
          }
        }
      }
    }
  }
}
```

The `api-key-inbound` policy validates the key from the `Authorization` header,
identifies the consumer, and attaches their metadata to the request context.
Invalid or missing keys get rejected with a `401` before they ever reach your
MCP handler.

### OAuth 2.0 and OIDC

For enterprise deployments, OAuth 2.0 with OpenID Connect provides a more robust
authentication framework. Instead of static keys, agents authenticate through
your identity provider (Auth0, Okta, Azure AD, etc.) and receive short-lived
JWTs.

Benefits over API keys:

- **Token expiration**: JWTs expire automatically, limiting the blast radius of
  a compromised token
- **Rich claims**: Tokens carry claims about the agent's identity, roles, and
  permissions
- **Standard protocols**: Works with any OAuth 2.0 compliant identity provider
- **Centralized management**: Revoke access from your IdP, not from each
  individual service

To configure JWT validation on your MCP server with Zuplo, add the OpenID
Connect policy to your inbound pipeline:

```json
{
  "name": "oidc-jwt-auth-inbound",
  "policyType": "open-id-jwt-auth-inbound",
  "handler": {
    "export": "OpenIdJwtInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "issuer": "https://your-tenant.auth0.com/",
      "audience": "https://your-mcp-server.example.com",
      "jwkUrl": "https://your-tenant.auth0.com/.well-known/jwks.json",
      "allowUnauthenticatedRequests": false
    }
  }
}
```

This policy validates the JWT signature against your identity provider's public
keys, checks the issuer and audience claims, verifies the token has not expired,
and attaches the decoded claims to the request context for downstream
authorization decisions.

### Agent Credential Provisioning

In practice, you will likely have multiple AI agents with different purposes and
different trust levels. A customer support agent should not have the same access
as an admin automation agent. A third-party partner's agent should not have the
same access as your internal agents.

The pattern is straightforward: issue separate credentials for each agent and
attach metadata that describes what that agent is allowed to do.

With API keys, this means creating a unique key per agent and associating it
with a consumer profile that includes role and permission metadata:

```typescript
// Example consumer metadata in Zuplo
{
  "name": "support-agent-prod",
  "metadata": {
    "role": "reader",
    "allowedTools": ["getTicket", "listTickets", "searchKnowledge"],
    "rateLimit": "100/minute",
    "team": "customer-support"
  }
}
```

With OAuth, this means configuring your identity provider with different client
credentials for each agent, each with appropriate scopes:

```
// Support agent: limited scopes
client_id: support-agent-prod
scopes: mcp:tools:read

// Admin agent: elevated scopes
client_id: admin-agent-prod
scopes: mcp:tools:read mcp:tools:write mcp:tools:admin
```

The key principle is that every agent should have its own identity with the
minimum permissions required for its function.

## Authorization and RBAC

Authentication tells you who the caller is. Authorization tells you what they
are allowed to do. For MCP servers, this means controlling which tools each
agent can invoke.

### Endpoint Scoping

The simplest authorization model is endpoint scoping: restrict which MCP tools
an agent can access based on their credentials. When your MCP server receives a
tool invocation request, check the agent's identity against a list of allowed
tools before executing anything.

This prevents a support agent from calling `deleteUser` or a read-only
integration from calling `updateInventory`. The tool might exist on the MCP
server, but the agent simply cannot invoke it.

### RBAC Patterns

Role-Based Access Control (RBAC) maps agent roles to tool permissions. Instead
of maintaining per-agent tool lists, you define roles and assign tools to each
role:

| Role   | Allowed Methods        | Example Tools                         |
| ------ | ---------------------- | ------------------------------------- |
| reader | GET                    | getUser, listOrders, searchProducts   |
| writer | GET, POST, PUT         | createOrder, updateProduct, getUser   |
| admin  | GET, POST, PUT, DELETE | deleteUser, createOrder, updateConfig |

Here is a TypeScript policy for Zuplo that enforces RBAC on MCP tool
invocations. This policy reads the agent's role from their consumer metadata and
checks it against a permissions map before allowing the tool call to proceed:

```typescript
import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime";

const ROLE_PERMISSIONS: Record<string, { methods: string[]; tools: string[] }> =
  {
    reader: {
      methods: ["GET"],
      tools: [
        "getUser",
        "listUsers",
        "getOrder",
        "listOrders",
        "searchProducts",
      ],
    },
    writer: {
      methods: ["GET", "POST", "PUT"],
      tools: [
        "getUser",
        "listUsers",
        "getOrder",
        "listOrders",
        "searchProducts",
        "createOrder",
        "updateProduct",
        "updateOrder",
      ],
    },
    admin: {
      methods: ["GET", "POST", "PUT", "DELETE"],
      tools: ["*"], // admin can access all tools
    },
  };

export default async function mcpAuthorizationPolicy(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const role = request.user?.data?.metadata?.role;

  if (!role || !ROLE_PERMISSIONS[role]) {
    return HttpProblems.forbidden(request, context, {
      detail: "Agent does not have an assigned role.",
    });
  }

  // Parse the MCP request body to extract the tool name
  const body = await request.clone().json();
  const toolName = body?.params?.name;

  if (!toolName) {
    // Not a tool invocation request, let it through
    return request;
  }

  const permissions = ROLE_PERMISSIONS[role];

  // Check if the tool is allowed for this role
  if (
    !permissions.tools.includes("*") &&
    !permissions.tools.includes(toolName)
  ) {
    context.log.warn(
      `Agent "${request.user?.sub}" with role "${role}" ` +
        `attempted to call unauthorized tool "${toolName}"`,
    );
    return HttpProblems.forbidden(request, context, {
      detail: `Tool "${toolName}" is not available for your role.`,
    });
  }

  return request;
}
```

This policy runs in the inbound pipeline before your MCP handler. Unauthorized
tool calls are rejected with a `403 Forbidden` response and logged for review.
Authorized calls pass through to the MCP server normally.

## Rate Limiting for AI Agents

AI agents can be aggressive callers. Unlike human users who pause to read
results, think, and click, agents execute tool calls as fast as they can process
responses. A single agent in a loop can generate hundreds of requests per
second. Multiply that across many agents and you have a recipe for backend
overload, runaway costs, and degraded service for everyone.

Per-agent rate limits are essential. Different agents should have different
limits based on their role, their expected usage patterns, and the cost of the
tools they access.

With Zuplo, you configure rate limiting as a policy on your MCP routes. Here is
an example that applies per-consumer rate limits:

```json
{
  "name": "rate-limit-inbound",
  "policyType": "rate-limit-inbound",
  "handler": {
    "export": "RateLimitInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "rateLimitBy": "user",
      "requestsAllowed": 100,
      "timeWindowMinutes": 1,
      "identifier": {
        "func": "$import(./modules/rate-limit-id)",
        "export": "rateLimitId"
      }
    }
  }
}
```

The `rateLimitBy: "user"` setting ensures each authenticated agent gets its own
rate limit bucket. A support agent burning through its 100 requests per minute
does not affect the admin agent's separate allocation.

For more granular control, you can set different limits for different tools.
Expensive operations like `generateReport` or `bulkUpdate` might have a limit of
10 requests per minute, while cheap read operations like `getStatus` can allow
500 per minute. Implement this with a custom rate limit identifier function that
includes the tool name:

```typescript
import { ZuploRequest } from "@zuplo/runtime";

export function rateLimitId(request: ZuploRequest): string {
  const toolName = request.headers.get("x-mcp-tool-name") ?? "default";
  return `${request.user?.sub}:${toolName}`;
}
```

When an agent exceeds its rate limit, Zuplo returns a `429 Too Many Requests`
response with `Retry-After` headers. Well-behaved agents will respect this and
back off. For agents that do not, the rate limit protects your backend
regardless.

## Audit Logging

Every MCP tool invocation should be logged. This is not optional for production
deployments. You need a record of what happened, when, who did it, and what the
result was.

An effective MCP audit log captures:

- **Agent identity**: Which agent (or agent operator) made the call
- **Tool name**: Which MCP tool was invoked
- **Parameters**: What arguments were passed to the tool
- **Timestamp**: When the invocation occurred
- **Response status**: Whether the call succeeded or failed
- **Latency**: How long the invocation took
- **Request ID**: A unique identifier for correlating logs across services

This data serves multiple purposes. For compliance, it proves that access
controls are enforced and that sensitive operations are traceable. For
debugging, it helps you understand what an agent was doing when something went
wrong. For optimization, it reveals usage patterns that inform rate limit tuning
and tool design.

Zuplo logs all of this automatically for every request that passes through your
gateway. Logs are available in real-time through the Zuplo dashboard and can be
streamed to your existing log infrastructure — Datadog, Splunk, or any other
destination — using Zuplo's log export plugins.

For additional MCP-specific context, you can add a custom logging policy that
extracts the tool name and parameters from the request body:

```typescript
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function mcpAuditLogPolicy(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const body = await request.clone().json();
  const toolName = body?.params?.name ?? "unknown";
  const toolArgs = body?.params?.arguments ?? {};

  context.log.info({
    event: "mcp_tool_invocation",
    agent: request.user?.sub,
    agentRole: request.user?.data?.metadata?.role,
    tool: toolName,
    arguments: toolArgs,
    requestId: context.requestId,
    timestamp: new Date().toISOString(),
  });

  return request;
}
```

This structured log output integrates cleanly with log aggregation systems and
makes it straightforward to build dashboards, alerts, and anomaly detection for
your MCP server traffic.

## Security Checklist for MCP Servers

Before deploying an MCP server to production, verify every item on this list:

1. **Always use HTTPS.** MCP tool calls carry parameters and return data that
   may be sensitive. Never expose an MCP server over plain HTTP, even for
   internal traffic.

2. **Require authentication on every endpoint.** No anonymous access. Every
   request must carry a valid API key or JWT. Reject unauthenticated requests at
   the gateway before they reach your MCP handler.

3. **Implement per-agent rate limits.** Every agent gets its own rate limit
   bucket. Set limits based on the agent's role and expected usage. Expensive
   tools should have stricter limits than cheap read operations.

4. **Scope tool access by agent role.** Define roles with specific tool
   permissions. An agent should only be able to invoke the tools it needs for
   its function. Enforce this in your authorization policy.

5. **Log all tool invocations.** Record the agent identity, tool name,
   parameters, response status, and timestamp for every call. Stream logs to
   your existing observability platform.

6. **Rotate agent credentials regularly.** API keys and client secrets should
   have expiration dates. Automate rotation so that credential compromise has a
   limited window of impact.

7. **Monitor for anomalous usage patterns.** Set up alerts for unusual behavior:
   an agent calling tools it has never called before, a sudden spike in request
   volume, or repeated authorization failures.

8. **Use an API gateway as the security layer.** Do not implement
   authentication, authorization, rate limiting, and logging directly in your
   MCP server code. Use a gateway like Zuplo to handle these concerns
   declaratively, consistently, and without cluttering your business logic.

## Implementation with Zuplo

Every security pattern described in this guide — authentication, authorization,
rate limiting, audit logging — is something Zuplo handles out of the box when
you add an MCP server to your Zuplo gateway routes.

Zuplo is an API gateway that runs at the edge. You configure it with routes,
handlers, and policies. To expose your API as an MCP server, you add Zuplo's
built-in MCP handler to a route. Security policies attach to that route and run
on every inbound request. The MCP server lives at the gateway level — your
backend API never sees MCP protocol messages at all.

Here is what the full picture looks like:

1. **Your API** runs wherever it runs: a cloud function, a container, a legacy
   server. It does not need to know about MCP at all.

2. **Zuplo sits in front of your API** as the gateway. It imports your OpenAPI
   spec and automatically generates MCP tool definitions for each endpoint.

3. **AI agents connect to the Zuplo MCP endpoint.** They discover available
   tools and invoke them using the standard MCP protocol.

4. **Zuplo enforces security policies** on every request: validates credentials,
   checks authorization, applies rate limits, logs the invocation.

5. **Authorized requests are forwarded to your API.** Your backend receives
   standard HTTP requests with full authentication context. It does not need to
   parse MCP protocol messages or enforce access control itself.

This architecture has several advantages. Your API stays clean — no MCP protocol
handling, no security policy code, no rate limit logic. Security policies are
defined declaratively in Zuplo's configuration and apply uniformly to all tool
invocations. Adding or removing tools is as simple as updating your OpenAPI
spec. And because Zuplo runs at the edge on Cloudflare's network, your MCP
server benefits from global distribution and low-latency routing.

The configuration is entirely declarative. Your route file defines the MCP
handler and the security policies that apply to it:

```json
{
  "paths": {
    "/mcp": {
      "post": {
        "x-zuplo-route": {
          "handler": {
            "export": "mcpServerHandler",
            "module": "$import(@zuplo/mcp)"
          },
          "policies": {
            "inbound": [
              "api-key-inbound",
              "mcp-authorization",
              "rate-limit-inbound",
              "mcp-audit-log"
            ]
          }
        }
      }
    }
  }
}
```

Four policies, applied in order: authenticate, authorize, rate limit, log. Each
one is configured independently and can be swapped, reordered, or removed
without touching the others. This is how security should work for MCP servers —
declarative, composable, and separate from your business logic.

## Get Started

Your MCP server deserves the same security treatment as any production API.
Authentication, authorization, rate limiting, and audit logging are not optional
features to add later — they are table stakes for any deployment that handles
real data.

Zuplo makes this straightforward. Sign up for a
[free Zuplo account](https://portal.zuplo.com/signup), import your OpenAPI spec,
enable the MCP server handler, and configure your security policies. Your agents
get a secure, production-ready MCP endpoint. Your backend stays clean. Your
security team stays happy.

[Get started with Zuplo](https://portal.zuplo.com/signup) and secure your MCP
servers today.