📅 Day 17 ⏱ 55 min 🔥 Level 3 — Ascend 🔐 Security

Authentication & Authorization
in MCP Servers

Open MCP servers are powerful — and dangerous. Learn how to lock down your MCP server with API keys, OAuth 2.0, RBAC, and AWS Secrets Manager so only the right people call the right tools.

An MCP server without authentication is like an AWS Lambda function with a public URL and no IAM policy — technically functional, practically a liability. Every production MCP server needs a clear answer to: "Who can call this tool, and what are they allowed to do?"
📋 What you'll learn today
🎯 Threat Model

Why MCP Auth Matters

When you expose an MCP server — whether locally via stdio or over the network via SSE/HTTP — you're exposing a set of callable functions to any client that can reach it. Without authentication, an attacker who discovers your endpoint can invoke your tools: delete database records, read private files, exfiltrate secrets, or rack up cloud bills.

The MCP spec itself is auth-agnostic — it defines the protocol, not the security layer. That means you own the security. Here's the standard threat model you must design for:

ThreatImpactMitigation
Unauthenticated tool callsFull tool access for anyoneAPI key / OAuth guard
Over-privileged clientCan call destructive toolsRBAC — scope per role
Stolen API keyImpersonationKey rotation + Secrets Manager
No audit trailCan't detect abuseCloudWatch structured logging
Prompt injection via tool resultLLM manipulated by dataOutput sanitization (Day 18)
🚨
Especially critical for SSE transport

stdio MCP servers run locally and inherit the user's OS permissions. SSE/HTTP MCP servers are network-accessible — they MUST have authentication before receiving any real traffic.

🔑 API Keys

API Key Authentication — Bearer Tokens

The simplest production-ready auth pattern. The client sends a secret key in the Authorization header with every request. Your MCP server validates it before processing any message. This works for both SSE and HTTP transports.

1
Generate and store API keys securely
Never hardcode keys. Use AWS Secrets Manager or environment variables loaded at startup.
# Generate a cryptographically secure key import secrets api_key = secrets.token_urlsafe(32) # Store in AWS Secrets Manager (see Section 5) # Output: "xK7mP2qN9vL4wR8jT1cA5dF6yH3bE0uI..."
2
Add auth middleware to your FastMCP server
Validate the Bearer token on every incoming request before it reaches any tool handler.
from fastmcp import FastMCP from fastmcp.server.middleware import Middleware import os, hmac, hashlib mcp = FastMCP("SecureServer") VALID_KEY = os.environ["MCP_API_KEY"] class ApiKeyMiddleware(Middleware): async def on_request(self, context, call_next): token = context.headers.get("authorization", "") if not token.startswith("Bearer "): raise PermissionError("Missing Authorization header") provided = token[7:] # Constant-time comparison prevents timing attacks if not hmac.compare_digest(provided, VALID_KEY): raise PermissionError("Invalid API key") return await call_next(context) mcp.add_middleware(ApiKeyMiddleware())
3
Client sends the key in every request
When configuring MCP in Claude Desktop or Claude Code, set the header in your config:
// claude_desktop_config.json { "mcpServers": { "my-server": { "url": "https://api.yourdomain.com/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY_HERE" } } } }
💡
Use hmac.compare_digest(), never ==

Direct string comparison (key == provided) is vulnerable to timing attacks — an attacker can measure nanosecond differences to guess the key character by character. hmac.compare_digest() always takes the same time regardless of where strings differ.

🔐 OAuth 2.0

OAuth 2.0 with MCP Servers

For multi-tenant MCP servers (many users, each with their own identity), OAuth 2.0 is the industry standard. The MCP spec defines an OAuth 2.0 flow specifically for MCP authorization — the client obtains an access token from an Authorization Server, then presents it to the MCP server.

The recommended flow for MCP is OAuth 2.0 with PKCE (Proof Key for Code Exchange) — which is secure even for public clients like CLI tools and desktop apps.

🏨

The Hotel Key Card Analogy

OAuth 2.0 is like a hotel. You show your ID at the front desk (Authorization Server) and prove who you are. They give you a key card (access token) with specific permissions — room 302 only, no pool access after 10pm. When you use the elevator or pool (MCP tools), it checks your key card, not your ID. The key card expires, and the hotel can revoke it instantly without changing the locks on every door.

# MCP OAuth 2.0 flow (simplified) using fastmcp + Cognito from fastmcp import FastMCP import httpx, json from jose import jwt, JWTError COGNITO_REGION = "us-east-1" USER_POOL_ID = "us-east-1_XXXXXXXXX" CLIENT_ID = "your-app-client-id" JWKS_URL = f"https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{USER_POOL_ID}/.well-known/jwks.json" # Cache JWKS to avoid fetching on every request _jwks = None async def get_jwks(): global _jwks if not _jwks: async with httpx.AsyncClient() as c: resp = await c.get(JWKS_URL) _jwks = resp.json() return _jwks async def verify_cognito_token(token: str) -> dict: jwks = await get_jwks() header = jwt.get_unverified_header(token) key = next(k for k in jwks["keys"] if k["kid"] == header["kid"]) claims = jwt.decode( token, key, algorithms=["RS256"], audience=CLIENT_ID ) return claims # contains: sub, email, cognito:groups, etc.
AWS Cognito is the fastest path

Cognito gives you a fully managed OAuth 2.0 Authorization Server with user pools, social login (Google/GitHub), and JWT token validation in under 30 minutes. Free tier: 50,000 MAUs. Perfect for MCP servers with external users.

🎭 RBAC

Role-Based Access Control for Tools

Authentication answers "who are you?" — Authorization answers "what are you allowed to do?" RBAC assigns users to roles (admin, analyst, readonly) and tools to permission groups. A tool call is only processed if the caller's role includes that tool's permission.

# RBAC implementation for MCP tools from functools import wraps from fastmcp import FastMCP, Context mcp = FastMCP("RBACServer") # Define permissions per role ROLE_PERMISSIONS = { "admin": {"read_data", "write_data", "delete_data", "run_reports"}, "analyst": {"read_data", "run_reports"}, "readonly": {"read_data"}, } def require_permission(permission: str): def decorator(func): @wraps(func) async def wrapper(ctx: Context, *args, **kwargs): role = ctx.meta.get("role", "readonly") if permission not in ROLE_PERMISSIONS.get(role, set()): raise PermissionError(f"Role '{role}' cannot use '{permission}'") return await func(ctx, *args, **kwargs) return wrapper return decorator @mcp.tool() @require_permission("delete_data") async def delete_record(ctx: Context, record_id: str) -> str: # Only admin role can reach this line return f"Deleted {record_id}" @mcp.tool() @require_permission("read_data") async def get_record(ctx: Context, record_id: str) -> str: # All roles can reach this line return f"Record: {record_id}"
🔒 Secrets Manager

AWS Secrets Manager Integration

Never store API keys in environment variables that get committed to source control. AWS Secrets Manager is the right place — it encrypts secrets at rest (KMS), enables automatic rotation, and provides fine-grained IAM access control. Your MCP server fetches the secret at startup (or caches it with a TTL).

import boto3, json from functools import lru_cache from datetime import datetime, timedelta _secrets_client = boto3.client("secretsmanager", region_name="us-east-1") _secret_cache: dict = {} def get_secret(secret_name: str, ttl_minutes: int = 15) -> dict: """Fetch secret with local TTL cache to avoid API throttling.""" cached = _secret_cache.get(secret_name) if cached and datetime.now() < cached["expires"]: return cached["value"] response = _secrets_client.get_secret_value(SecretId=secret_name) value = json.loads(response["SecretString"]) _secret_cache[secret_name] = { "value": value, "expires": datetime.now() + timedelta(minutes=ttl_minutes) } return value # Usage at server startup secrets = get_secret("mcp/production/api-keys") MCP_API_KEY = secrets["mcp_api_key"] DB_PASSWORD = secrets["db_password"] EXTERNAL_KEY = secrets["external_service_key"]
🏗️
IAM policy for your MCP server

Grant your ECS task role or Lambda execution role only the specific secrets it needs: secretsmanager:GetSecretValue on arn:aws:secretsmanager:region:account:secret:mcp/*. Never use * on resource.

📋 Audit Logging

Audit Logging Every Tool Call

Security without visibility is incomplete. Every tool call should emit a structured log to CloudWatch Logs — who called it, what arguments were passed, what was returned, and how long it took. This enables intrusion detection, debugging, and compliance audits.

import json, time, logging from fastmcp import FastMCP, Context # Use structured JSON logging for CloudWatch Insights queries logger = logging.getLogger("mcp.audit") class AuditMiddleware(Middleware): async def on_tool_call(self, context, call_next): start = time.time() tool_name = context.tool_name caller = context.meta.get("user_id", "anonymous") try: result = await call_next(context) logger.info(json.dumps({ "event": "tool_call", "status": "success", "tool": tool_name, "caller": caller, "duration_ms": round((time.time() - start) * 1000) })) return result except Exception as e: logger.error(json.dumps({ "event": "tool_call", "status": "error", "tool": tool_name, "caller": caller, "error": str(e) })) raise
🏢 Enterprise Use Case: Detecting Abuse
A financial services company runs an MCP server with a query_customer_data tool. With audit logging in CloudWatch, their security team sets a CloudWatch Alarm: "alert if any single user_id calls query_customer_data more than 100 times in 5 minutes." This automatically catches both runaway automation bugs and deliberate data exfiltration attempts.
🧠 Knowledge Check — Day 17
4 questions on MCP authentication & authorization
QUESTION 01 / 04
Why should you use hmac.compare_digest() instead of == when comparing API keys?
A hmac.compare_digest() is faster for long strings
B It prevents timing attacks by always taking the same time regardless of where strings differ
C It automatically hashes the key before comparing
D It works only in Python 3.11+
✅ Correct answer: B. Timing attacks measure how long a comparison takes — early exit on first mismatch leaks information about how many characters matched. hmac.compare_digest() always iterates the full string, making timing attacks infeasible.
QUESTION 02 / 04
Which AWS service is the recommended way to store MCP server API keys in production?
A Environment variables in a .env file checked into source control
B AWS Systems Manager Parameter Store (free tier)
C AWS Secrets Manager with TTL caching in the server
D S3 bucket with server-side encryption
✅ Correct answer: C. AWS Secrets Manager provides encryption at rest (KMS), automatic rotation, fine-grained IAM access control, and versioning. TTL caching avoids API throttling. Parameter Store is also acceptable for lower-sensitivity configs, but Secrets Manager is preferred for credentials.
QUESTION 03 / 04
In RBAC for MCP tools, an "analyst" role has permissions: {read_data, run_reports}. What happens when an analyst calls the delete_record tool (which requires "delete_data" permission)?
A The call succeeds because analysts can see all tools
B A PermissionError is raised before the tool function executes
C The tool is hidden from the analyst's tool list
D It falls back to read-only mode
✅ Correct answer: B. The @require_permission decorator raises a PermissionError before the actual function body runs — the analyst gets a clear error. Optionally you can also filter the tools list per role so analysts never see destructive tools at all (defense in depth).
QUESTION 04 / 04
OAuth 2.0 with PKCE is preferred over basic OAuth 2.0 for MCP because:
A It eliminates the need for HTTPS
B It is secure for public clients (CLI, desktop apps) that cannot safely store a client secret
C It issues tokens that never expire
D It bypasses the authorization server
✅ Correct answer: B. PKCE (Proof Key for Code Exchange) was designed for clients that can't keep a client_secret secure — like native desktop apps or CLI tools. It uses a dynamically generated code verifier/challenge pair instead, preventing authorization code interception attacks.
Up Next — Day 18
Rate Limiting, Security & Input Validation
Stop prompt injection, tool abuse, and DDoS attacks before they reach your business logic.
Day 18 →