MCP Tutorials/Authentication

MCP Authentication Guide

Secure your MCP servers with API keys, OAuth tokens, and environment-based credentials.

~12 min readIntermediateSecurity

MCP servers often need to connect to external APIs and services that require authentication. This guide covers multiple authentication strategies, from simple API keys to OAuth tokens, with security best practices for production deployments.

What You'll Learn

  • Environment variable-based authentication
  • API key management and rotation
  • OAuth token handling
  • Secrets management best practices
  • Multi-environment configurations

Environment Variables: The Foundation

The most common and recommended way to handle credentials in MCP servers is through environment variables. Claude Desktop and other clients support passing environment variables to MCP server processes.

Basic Environment Variable Usage

Here's a Python MCP server that uses environment variables for authentication:

# server.py
import os
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("authenticated-server")

# Read credentials from environment
API_KEY = os.environ.get("MY_SERVICE_API_KEY")
if not API_KEY:
    raise ValueError("MY_SERVICE_API_KEY environment variable required")

@mcp.tool()
def fetch_data(query: str) -> str:
    """Fetch data from authenticated API."""
    import httpx
    
    response = httpx.get(
        "https://api.example.com/data",
        params={"q": query},
        headers={"Authorization": f"Bearer {API_KEY}"}
    )
    return response.json()

if __name__ == "__main__":
    mcp.run()

Claude Desktop Configuration

Configure Claude Desktop to pass environment variables to your server:

{
  "mcpServers": {
    "my-authenticated-server": {
      "command": "python",
      "args": ["/path/to/server.py"],
      "env": {
        "MY_SERVICE_API_KEY": "sk-your-api-key-here"
      }
    }
  }
}

⚠️ Security Note

Storing API keys directly in config files is convenient for development but not ideal for production. We'll cover better approaches below.

Managing Multiple Credentials

Real-world MCP servers often need to interact with multiple services. Here's a pattern for managing multiple credentials cleanly:

# config.py
import os
from dataclasses import dataclass

@dataclass
class Credentials:
    github_token: str
    openai_key: str
    database_url: str
    
    @classmethod
    def from_env(cls) -> "Credentials":
        """Load credentials from environment variables."""
        missing = []
        
        github_token = os.environ.get("GITHUB_TOKEN")
        if not github_token:
            missing.append("GITHUB_TOKEN")
            
        openai_key = os.environ.get("OPENAI_API_KEY")
        if not openai_key:
            missing.append("OPENAI_API_KEY")
            
        database_url = os.environ.get("DATABASE_URL")
        if not database_url:
            missing.append("DATABASE_URL")
            
        if missing:
            raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
            
        return cls(
            github_token=github_token,
            openai_key=openai_key,
            database_url=database_url
        )

# server.py
from mcp.server.fastmcp import FastMCP
from config import Credentials

mcp = FastMCP("multi-auth-server")
creds = Credentials.from_env()

@mcp.tool()
def github_search(query: str) -> str:
    """Search GitHub repositories."""
    import httpx
    response = httpx.get(
        "https://api.github.com/search/repositories",
        params={"q": query},
        headers={"Authorization": f"token {creds.github_token}"}
    )
    return response.json()

@mcp.tool()
def ai_summarize(text: str) -> str:
    """Summarize text using OpenAI."""
    import httpx
    response = httpx.post(
        "https://api.openai.com/v1/chat/completions",
        headers={"Authorization": f"Bearer {creds.openai_key}"},
        json={
            "model": "gpt-4o-mini",
            "messages": [{"role": "user", "content": f"Summarize: {text}"}]
        }
    )
    return response.json()["choices"][0]["message"]["content"]

OAuth Token Handling

For services that require OAuth (like Google APIs, Slack, etc.), you'll typically need to handle token refresh. Here's a pattern for that:

# oauth_handler.py
import os
import json
import time
from pathlib import Path
import httpx

class OAuthTokenManager:
    """Manage OAuth tokens with automatic refresh."""
    
    def __init__(
        self,
        client_id: str,
        client_secret: str,
        token_url: str,
        token_file: Path
    ):
        self.client_id = client_id
        self.client_secret = client_secret
        self.token_url = token_url
        self.token_file = token_file
        self._token_data = None
        self._load_token()
    
    def _load_token(self):
        """Load token from file if it exists."""
        if self.token_file.exists():
            self._token_data = json.loads(self.token_file.read_text())
    
    def _save_token(self):
        """Save token to file."""
        self.token_file.parent.mkdir(parents=True, exist_ok=True)
        self.token_file.write_text(json.dumps(self._token_data))
    
    def _refresh_token(self):
        """Refresh the access token using refresh token."""
        if not self._token_data or "refresh_token" not in self._token_data:
            raise ValueError("No refresh token available. Re-authorize required.")
        
        response = httpx.post(
            self.token_url,
            data={
                "grant_type": "refresh_token",
                "refresh_token": self._token_data["refresh_token"],
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            }
        )
        response.raise_for_status()
        
        new_data = response.json()
        self._token_data["access_token"] = new_data["access_token"]
        self._token_data["expires_at"] = time.time() + new_data.get("expires_in", 3600)
        
        if "refresh_token" in new_data:
            self._token_data["refresh_token"] = new_data["refresh_token"]
        
        self._save_token()
    
    def get_access_token(self) -> str:
        """Get a valid access token, refreshing if necessary."""
        if not self._token_data:
            raise ValueError("No token available. Authorization required.")
        
        # Refresh if expired or expiring soon (within 5 minutes)
        expires_at = self._token_data.get("expires_at", 0)
        if time.time() > expires_at - 300:
            self._refresh_token()
        
        return self._token_data["access_token"]

# Usage in MCP server
from mcp.server.fastmcp import FastMCP
from oauth_handler import OAuthTokenManager
from pathlib import Path

mcp = FastMCP("google-drive-server")

token_manager = OAuthTokenManager(
    client_id=os.environ["GOOGLE_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
    token_url="https://oauth2.googleapis.com/token",
    token_file=Path.home() / ".config" / "mcp" / "google_token.json"
)

@mcp.tool()
def list_drive_files(folder_id: str = "root") -> str:
    """List files in Google Drive folder."""
    import httpx
    
    token = token_manager.get_access_token()
    response = httpx.get(
        "https://www.googleapis.com/drive/v3/files",
        params={"q": f"'{folder_id}' in parents"},
        headers={"Authorization": f"Bearer {token}"}
    )
    return response.json()

Using Secrets Managers

For production deployments, consider using a secrets manager instead of plain environment variables. Here are examples for popular options:

1Password CLI Integration

# Claude Desktop config using 1Password
{
  "mcpServers": {
    "secure-server": {
      "command": "op",
      "args": [
        "run",
        "--",
        "python",
        "/path/to/server.py"
      ],
      "env": {
        "MY_API_KEY": "op://vault/item/field"
      }
    }
  }
}

# The 'op run' command automatically injects secrets
# from 1Password references like op://vault/item/field

AWS Secrets Manager

# secrets_loader.py
import boto3
import json
from functools import lru_cache

@lru_cache(maxsize=1)
def get_secrets(secret_name: str, region: str = "us-east-1") -> dict:
    """Load secrets from AWS Secrets Manager."""
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

# Usage in server
from mcp.server.fastmcp import FastMCP
from secrets_loader import get_secrets

mcp = FastMCP("aws-secrets-server")
secrets = get_secrets("my-mcp-server-secrets")

@mcp.tool()
def secure_operation() -> str:
    """Perform operation using secrets from AWS."""
    api_key = secrets["api_key"]
    # Use the secret...
    return "Done"

HashiCorp Vault

# vault_loader.py
import hvac
import os

def get_vault_secrets(path: str) -> dict:
    """Load secrets from HashiCorp Vault."""
    client = hvac.Client(
        url=os.environ.get("VAULT_ADDR", "http://localhost:8200"),
        token=os.environ.get("VAULT_TOKEN")
    )
    
    response = client.secrets.kv.v2.read_secret_version(path=path)
    return response["data"]["data"]

# Usage
secrets = get_vault_secrets("mcp/my-server")
api_key = secrets["api_key"]

Multi-Environment Configuration

Handle different credentials for development, staging, and production:

# config.py
import os
from dataclasses import dataclass
from typing import Literal

Environment = Literal["development", "staging", "production"]

@dataclass
class Config:
    environment: Environment
    api_key: str
    api_base_url: str
    debug: bool
    
    @classmethod
    def load(cls) -> "Config":
        env = os.environ.get("MCP_ENV", "development")
        
        if env == "production":
            return cls(
                environment="production",
                api_key=os.environ["PROD_API_KEY"],
                api_base_url="https://api.example.com",
                debug=False
            )
        elif env == "staging":
            return cls(
                environment="staging",
                api_key=os.environ["STAGING_API_KEY"],
                api_base_url="https://staging-api.example.com",
                debug=True
            )
        else:
            return cls(
                environment="development",
                api_key=os.environ.get("DEV_API_KEY", "dev-key-for-testing"),
                api_base_url="http://localhost:8000",
                debug=True
            )

# server.py
from mcp.server.fastmcp import FastMCP
from config import Config

config = Config.load()
mcp = FastMCP(f"my-server-{config.environment}")

@mcp.tool()
def get_environment() -> str:
    """Check which environment the server is running in."""
    return f"Running in {config.environment} mode"

Security Best Practices

✓ Do

  • Use environment variables or secrets managers for credentials
  • Implement least-privilege access (only request scopes you need)
  • Rotate credentials regularly
  • Use short-lived tokens when possible (OAuth)
  • Log authentication failures (without logging the credentials)
  • Validate all input before using in API calls

✗ Don't

  • Hardcode credentials in source code
  • Commit config files with real credentials to git
  • Log credentials or tokens (even in debug mode)
  • Share credentials between environments
  • Use overly permissive API scopes
  • Store credentials in plain text files

Credential Validation Pattern

# validation.py
import os
import sys

def validate_credentials():
    """Validate all required credentials at startup."""
    required = {
        "GITHUB_TOKEN": "GitHub Personal Access Token",
        "OPENAI_API_KEY": "OpenAI API Key",
    }
    
    optional = {
        "SLACK_BOT_TOKEN": "Slack Bot Token (for notifications)",
    }
    
    missing = []
    for var, description in required.items():
        if not os.environ.get(var):
            missing.append(f"  - {var}: {description}")
    
    if missing:
        print("❌ Missing required credentials:", file=sys.stderr)
        print("\n".join(missing), file=sys.stderr)
        print("\nPlease set these environment variables.", file=sys.stderr)
        sys.exit(1)
    
    # Warn about optional missing credentials
    for var, description in optional.items():
        if not os.environ.get(var):
            print(f"⚠️  Optional: {var} not set ({description})", file=sys.stderr)
    
    print("✅ All required credentials validated")

# Call at server startup
if __name__ == "__main__":
    validate_credentials()
    
    from mcp.server.fastmcp import FastMCP
    mcp = FastMCP("validated-server")
    # ... rest of server
    mcp.run()

TypeScript Authentication

The same patterns work in TypeScript with the official MCP SDK:

// config.ts
import { z } from 'zod';

const ConfigSchema = z.object({
  apiKey: z.string().min(1, 'API key is required'),
  apiSecret: z.string().optional(),
  environment: z.enum(['development', 'staging', 'production']).default('development'),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(): Config {
  const result = ConfigSchema.safeParse({
    apiKey: process.env.API_KEY,
    apiSecret: process.env.API_SECRET,
    environment: process.env.NODE_ENV,
  });
  
  if (!result.success) {
    console.error('Configuration error:', result.error.format());
    process.exit(1);
  }
  
  return result.data;
}

// server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { loadConfig } from './config.js';

const config = loadConfig();
const server = new McpServer({ name: 'authenticated-ts-server', version: '1.0.0' });

server.tool(
  'secure_fetch',
  { url: z.string().url() },
  async ({ url }) => {
    const response = await fetch(url, {
      headers: { 'Authorization': `Bearer ${config.apiKey}` }
    });
    return { content: [{ type: 'text', text: await response.text() }] };
  }
);

const transport = new StdioServerTransport();
server.connect(transport);

Summary

Authentication in MCP servers follows the same best practices as any backend service:

  1. Use environment variables as the foundation
  2. Validate credentials at startup to fail fast
  3. Consider secrets managers for production (1Password, AWS, Vault)
  4. Handle OAuth properly with token refresh
  5. Separate environments with different credentials
  6. Never log or commit actual credentials

The key insight: Claude Desktop (and other MCP clients) can pass environment variables to your server process. Build your authentication around that capability, and you can keep credentials secure while maintaining easy local development.

Get updates in your inbox

Tutorials, updates, and best practices for Model Context Protocol.

No spam. Unsubscribe anytime.