Zuplo
Model Context Protocol

Securing MCP Servers: Authentication, Authorization, and Access Control

Nate TottenNate Totten
February 26, 2026
11 min read

Secure your MCP servers with OAuth, API keys, RBAC, and endpoint scoping. Practical patterns for authenticating AI agents and controlling access to MCP tools.

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 — 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:

JSONjson
{
  "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:

JSONjson
{
  "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:

TypeScripttypescript
// 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:

plaintext
// 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:

RoleAllowed MethodsExample Tools
readerGETgetUser, listOrders, searchProducts
writerGET, POST, PUTcreateOrder, updateProduct, getUser
adminGET, POST, PUT, DELETEdeleteUser, 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:

JSONjson
{
  "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:

TypeScripttypescript
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:

TypeScripttypescript
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:

JSONjson
{
  "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, 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 and secure your MCP servers today.