Introduction

Authentication tells you who the user is; authorization tells you what they can do. MCP servers in production need granular access control that goes beyond simple authentication: which users can invoke which tools, what resources they can access, and what operations are permitted. This lesson covers tool-level, resource-level, and role-based authorization patterns.

Key Concepts

  • Tool-Level Access Control: Restricting which tools a user or role can invoke. A read-only user should not be able to call delete_file.
  • Resource-Level Permissions: Controlling access to specific resources (files, database rows, API endpoints) based on ownership or role.
  • RBAC (Role-Based Access Control): Assigning permissions to roles (admin, editor, viewer) and mapping users to roles.
  • API Key Authentication: A simpler authentication mechanism for server-to-server communication where OAuth flows are impractical.
  • Audit Logging: Recording every tool invocation with user identity, tool name, arguments, timestamp, and result status for compliance and forensics.

Real World Context

A SaaS company deploys an MCP server that lets AI assistants manage customer support tickets. Support agents can view and update tickets, team leads can also reassign tickets and view analytics, and admins can delete tickets and configure workflows. Without proper authorization, a support agent's AI assistant could delete tickets or access another team's data.

Deep Dive

Tool-Level Access Control

Implement middleware that checks permissions before tool execution:

typescript
interface Permission {
  tools: string[];       // Allowed tool names
  resources: string[];   // Allowed resource patterns
  operations: ("read" | "write" | "delete")[];
}

const ROLES: Record<string, Permission> = {
  viewer: {
    tools: ["search_tickets", "get_ticket"],
    resources: ["tickets/*"],
    operations: ["read"]
  },
  agent: {
    tools: ["search_tickets", "get_ticket", "update_ticket", "add_comment"],
    resources: ["tickets/*", "comments/*"],
    operations: ["read", "write"]
  },
  admin: {
    tools: ["*"],
    resources: ["*"],
    operations: ["read", "write", "delete"]
  }
};

function authorize(userRole: string, toolName: string): boolean {
  const perms = ROLES[userRole];
  if (!perms) return false;
  return perms.tools.includes("*") || perms.tools.includes(toolName);
}

This role mapping ensures that tool access is explicitly granted, not implicitly available.

Extracting User Identity from Tokens

The authorization middleware decodes the access token to determine the user and their role:

typescript
import jwt from "jsonwebtoken";

interface UserContext {
  userId: string;
  email: string;
  role: string;
  scopes: string[];
}

async function extractUser(authHeader: string): Promise<UserContext> {
  const token = authHeader.replace("Bearer ", "");
  const decoded = jwt.verify(token, publicKey, {
    algorithms: ["RS256"],
    audience: "https://mcp.example.com",
    issuer: "https://auth.example.com"
  }) as jwt.JwtPayload;

  return {
    userId: decoded.sub!,
    email: decoded.email,
    role: decoded.role,
    scopes: decoded.scope?.split(" ") ?? []
  };
}

The audience check (RFC 8707) ensures the token was issued specifically for this MCP server, not stolen from another service.

Audit Logging

Log every tool invocation for compliance and security monitoring:

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

function createAuditedTool(
  server: McpServer,
  name: string,
  description: string,
  schema: Record<string, z.ZodTypeAny>,
  handler: Function,
  requiredRole: string
) {
  server.tool(name, description, schema, async (args, extra) => {
    const user = await extractUser(extra.authHeader);
    const startTime = Date.now();

    // Authorization check
    if (!authorize(user.role, name)) {
      server.sendLoggingMessage({
        level: "warning",
        logger: "audit",
        data: {
          event: "authorization_denied",
          tool: name,
          user: user.userId,
          role: user.role,
          timestamp: new Date().toISOString()
        }
      });
      throw new Error(`Access denied: role '${user.role}' cannot invoke '${name}'`);
    }

    // Execute tool
    try {
      const result = await handler(args, user);
      server.sendLoggingMessage({
        level: "info",
        logger: "audit",
        data: {
          event: "tool_invocation",
          tool: name,
          user: user.userId,
          duration_ms: Date.now() - startTime,
          status: "success"
        }
      });
      return result;
    } catch (error) {
      server.sendLoggingMessage({
        level: "error",
        logger: "audit",
        data: {
          event: "tool_invocation",
          tool: name,
          user: user.userId,
          duration_ms: Date.now() - startTime,
          status: "error",
          error: (error as Error).message
        }
      });
      throw error;
    }
  });
}

This wrapper adds authorization checks and audit logging to every tool without cluttering individual tool implementations.

API Key Authentication for Server-to-Server

When MCP servers communicate with backend services (not user-facing), use API keys:

typescript
function validateApiKey(request: Request): boolean {
  const apiKey = request.headers.get("x-api-key");
  if (!apiKey) return false;

  // Constant-time comparison to prevent timing attacks
  const expected = Buffer.from(process.env.API_KEY!);
  const received = Buffer.from(apiKey);
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

Constant-time comparison prevents timing attacks that could leak the key character by character.

Common Pitfalls

  1. Checking authentication but not authorization: Verifying a valid token doesn't mean the user has permission to invoke a specific tool. Always check both.
  2. Logging full arguments: Audit logs should record that a tool was called, but must sanitize sensitive arguments (passwords, PII, file contents).
  3. Hardcoding roles in tool handlers: Embed authorization logic in middleware, not in individual tool handlers. This ensures consistent enforcement and easy updates.

Best Practices

  1. Use role-based access control: Map users to roles and roles to permissions. This scales better than per-user permission lists.
  2. Validate token audience: Always check RFC 8707 audience to ensure tokens are used only at their intended server.
  3. Log both successes and failures: Successful invocations establish baseline behavior; failed attempts reveal potential attacks.

Summary

  • Tool-level access control restricts which tools each role can invoke
  • Resource-level permissions control access to specific data based on ownership or role
  • RBAC scales authorization by mapping users to roles with predefined permission sets
  • Audit logging must capture every tool invocation with user, tool, duration, and status
  • API keys with constant-time comparison are appropriate for server-to-server authentication

Code Examples

typescript
const ROLES: Record<string, { tools: string[], operations: string[] }> = {
  viewer: { tools: ["search", "get"], operations: ["read"] },
  editor: { tools: ["search", "get", "update", "create"], operations: ["read", "write"] },
  admin:  { tools: ["*"], operations: ["read", "write", "delete"] }
};

function authorize(role: string, tool: string): boolean {
  const perms = ROLES[role];
  if (!perms) return false;
  return perms.tools.includes("*") || perms.tools.includes(tool);
}
✓ Completed