Building MCP Servers
Guide

Adding Authentication to Python MCP Servers: Keys, OAuth, and Permissions

How to add MCP server Python authentication with API keys, OAuth 2.1, environment variable secrets, and per-tool permission checks.

11 min read
Updated February 26, 2026
By MCPServerSpot Team

Securing a Python MCP server requires three layers of authentication: validating that incoming requests come from authorized clients, protecting the secrets your server uses to access external services, and controlling which tools each user or agent can invoke. This guide covers all three with practical code examples using the official MCP Python SDK.

Every MCP server that accesses sensitive data or external services needs authentication. A server that queries your production database, sends messages on your behalf, or modifies files must verify that the caller is authorized. Without authentication, anyone who can reach your server can invoke its tools.

If you are building your first MCP server, start with our Python Quickstart to get a working server, then return here to add security. For broader security architecture, see the MCP Security Model.

Authentication Architecture in MCP

MCP authentication works differently depending on the transport:

TransportAuthentication MethodTypical Use Case
stdioInherited from the host process; no separate authLocal servers running as Claude Desktop child processes
SSE / Streamable HTTPHTTP headers, OAuth 2.1 tokens, API keysRemote servers accessible over the network

For local servers using stdio transport, the server inherits the permissions of the user running the MCP client. There is no network boundary, so there is no need for network-level authentication. Security comes from sandboxing (restricting which directories or databases the server can access).

For remote servers using HTTP-based transports, you need explicit authentication because the server is accessible over the network. This guide focuses primarily on remote server authentication, with guidance on secret management that applies to both local and remote servers.

Managing Secrets with Environment Variables

The most fundamental security practice for any MCP server is never hardcoding secrets. API keys, database passwords, and OAuth credentials should come from environment variables.

Here is the pattern for loading and validating secrets at server startup:

import os
import sys
from mcp.server.fastmcp import FastMCP

# Load required secrets from environment
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
DATABASE_URL = os.environ.get("DATABASE_URL")

# Fail fast if required secrets are missing
if not GITHUB_TOKEN:
    print("Error: GITHUB_TOKEN environment variable is required", file=sys.stderr)
    sys.exit(1)

if not DATABASE_URL:
    print("Error: DATABASE_URL environment variable is required", file=sys.stderr)
    sys.exit(1)

mcp = FastMCP("secure-server")

Setting Environment Variables for Claude Desktop

When configuring a local MCP server in Claude Desktop, pass environment variables through the configuration file:

{
  "mcpServers": {
    "secure-server": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/server", "mcp", "run", "server.py"],
      "env": {
        "GITHUB_TOKEN": "ghp_your_token_here",
        "DATABASE_URL": "postgresql://user:pass@localhost/mydb"
      }
    }
  }
}

For production deployments, use your platform's secrets management service instead:

PlatformSecrets Solution
AWSAWS Secrets Manager or SSM Parameter Store
Google CloudGoogle Secret Manager
AzureAzure Key Vault
KubernetesKubernetes Secrets
Docker ComposeDocker secrets or .env files (not committed to Git)

API Key Authentication for Remote Servers

For remote MCP servers that you control, API key authentication is the simplest approach. Clients include a key in their request headers, and your server validates it before processing any MCP messages.

Here is how to implement API key validation in a Python MCP server using the Streamable HTTP transport:

import os
import hashlib
import hmac
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from mcp.server.fastmcp import FastMCP

# Load the expected API key from environment
VALID_API_KEY = os.environ.get("MCP_API_KEY", "")

mcp = FastMCP("authenticated-server")


def verify_api_key(request: Request) -> bool:
    """Check if the request contains a valid API key."""
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        return False
    provided_key = auth_header[7:]  # Strip "Bearer " prefix
    # Use constant-time comparison to prevent timing attacks
    return hmac.compare_digest(provided_key, VALID_API_KEY)


@mcp.tool()
def get_user_data(user_id: str) -> str:
    """Retrieve user data by ID.

    Args:
        user_id: The unique identifier of the user.
    """
    # This tool is only accessible after authentication
    return f"User data for {user_id}: name=Alice, role=admin"

The key security detail here is using hmac.compare_digest instead of a simple == comparison. The standard equality check can leak information through timing differences (a longer matching prefix takes longer to compare), while hmac.compare_digest always takes the same amount of time regardless of where the strings differ.

Generating Secure API Keys

Generate API keys that are long enough to be secure and URL-safe:

import secrets

def generate_api_key() -> str:
    """Generate a cryptographically secure API key."""
    return secrets.token_urlsafe(32)

# Example output: "dBjftJeZ4CVP-mB92pU3t_Nqx2M7Y8vTO5Kh4R_Xmgs"

Use at least 32 bytes (256 bits) of randomness. Store the generated key securely and distribute it to authorized clients through a secure channel.

OAuth 2.1 Token Handling

For MCP servers that need to access external services on behalf of users, OAuth 2.1 is the standard approach. The MCP specification includes built-in support for OAuth 2.1 authentication flows.

Here is how to implement OAuth token validation in your server:

import httpx
from dataclasses import dataclass


@dataclass
class TokenInfo:
    """Validated OAuth token information."""
    user_id: str
    scopes: list
    expires_at: int


async def validate_oauth_token(token: str) -> TokenInfo:
    """Validate an OAuth 2.1 access token by introspecting it
    with the authorization server.

    Args:
        token: The Bearer token from the client request.
    Returns:
        TokenInfo with the validated user and scope information.
    Raises:
        ValueError: If the token is invalid or expired.
    """
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://auth.example.com/oauth/introspect",
            data={
                "token": token,
                "client_id": os.environ.get("OAUTH_CLIENT_ID"),
                "client_secret": os.environ.get("OAUTH_CLIENT_SECRET"),
            },
        )

    data = response.json()
    if not data.get("active"):
        raise ValueError("Token is invalid or expired")

    return TokenInfo(
        user_id=data["sub"],
        scopes=data.get("scope", "").split(),
        expires_at=data.get("exp", 0),
    )

OAuth Flow Overview for MCP

The MCP specification defines a standard OAuth 2.1 flow for remote servers:

StepActorAction
1ClientDiscovers the server's OAuth metadata at /.well-known/oauth-authorization-server
2ClientRedirects the user to the authorization endpoint
3UserAuthenticates and grants permissions
4Auth ServerIssues an authorization code to the client
5ClientExchanges the code for an access token
6ClientIncludes the token in MCP requests via the Authorization header
7MCP ServerValidates the token and processes the request

For a complete OAuth implementation walkthrough, see our dedicated MCP OAuth Implementation Guide.

Per-Tool Permission Checks

Not every authenticated user should have access to every tool. Implement per-tool permission checks to enforce the principle of least privilege.

Here is a pattern for role-based tool access:

from functools import wraps

# Define which roles can access which tools
TOOL_PERMISSIONS = {
    "read_data": ["viewer", "editor", "admin"],
    "write_data": ["editor", "admin"],
    "delete_data": ["admin"],
    "manage_users": ["admin"],
}


def get_current_user_role() -> str:
    """Get the role of the currently authenticated user.
    In a real implementation, this would extract the role
    from the validated OAuth token or session.
    """
    # Placeholder - replace with actual token/session lookup
    return os.environ.get("USER_ROLE", "viewer")


@mcp.tool()
def read_data(table: str, query: str) -> str:
    """Read data from a table using a query.

    Args:
        table: The name of the table to query.
        query: The SQL query to execute (SELECT only).
    """
    role = get_current_user_role()
    allowed_roles = TOOL_PERMISSIONS.get("read_data", [])
    if role not in allowed_roles:
        return "Permission denied: your role does not have read access."

    # Execute the query (implementation omitted)
    return f"Results from {table}: ..."


@mcp.tool()
def delete_data(table: str, record_id: str) -> str:
    """Delete a record from a table.

    Args:
        table: The name of the table.
        record_id: The ID of the record to delete.
    """
    role = get_current_user_role()
    allowed_roles = TOOL_PERMISSIONS.get("delete_data", [])
    if role not in allowed_roles:
        return "Permission denied: only admins can delete data."

    # Delete the record (implementation omitted)
    return f"Record {record_id} deleted from {table}."

Permission Check Best Practices

PracticeWhy It Matters
Deny by defaultIf a tool is not listed in the permissions map, no one can access it
Check at the tool levelDo not rely on transport-level auth alone; verify permissions inside each tool
Log access attemptsRecord who called which tool with what arguments for audit trails
Return clear errorsTell the caller why access was denied so they can request appropriate permissions
Use the narrowest roleGrant each user the minimum role they need; default to "viewer"

Credential Storage Best Practices

How you store credentials depends on your deployment model:

Local Servers (stdio transport)

For local servers, credentials are stored in the Claude Desktop configuration file or in environment variables set in your shell profile.

# Option 1: Add to your shell profile (~/.zshrc or ~/.bashrc)
export GITHUB_TOKEN="ghp_your_token_here"
export DATABASE_URL="postgresql://user:pass@localhost/mydb"

# Option 2: Use a .env file (not committed to Git)
# Then load it in your server with python-dotenv

If you use a .env file, add it to your .gitignore immediately:

# .gitignore
.env
.env.local
*.pem
*.key

Remote Servers (HTTP transport)

For remote servers, never store credentials in files on the server. Use your platform's secrets manager:

# Example: Loading secrets from AWS Secrets Manager
import boto3
import json


def load_secrets_from_aws(secret_name: str) -> dict:
    """Load secrets from AWS Secrets Manager.

    Args:
        secret_name: The name or ARN of the secret.
    Returns:
        Dictionary of secret key-value pairs.
    """
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])


# Load at startup
secrets = load_secrets_from_aws("mcp-server/production")
GITHUB_TOKEN = secrets["github_token"]
DATABASE_URL = secrets["database_url"]

Input Validation as a Security Layer

Authentication verifies who is calling your server. Input validation ensures that what they send is safe. Every tool should validate its inputs:

import re


@mcp.tool()
def query_database(table_name: str, limit: int = 100) -> str:
    """Query a database table with a row limit.

    Args:
        table_name: Name of the table to query. Must be alphanumeric with underscores only.
        limit: Maximum number of rows to return (1-1000).
    """
    # Validate table name to prevent SQL injection
    if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", table_name):
        return "Error: Invalid table name. Use only letters, numbers, and underscores."

    # Validate limit range
    if limit < 1 or limit > 1000:
        return "Error: Limit must be between 1 and 1000."

    # Safe to query (using parameterized queries, of course)
    return f"Querying {table_name} with limit {limit}..."

Key validation rules:

  • Sanitize all string inputs that will be used in SQL, file paths, or shell commands.
  • Enforce range limits on numeric inputs to prevent resource exhaustion.
  • Validate formats for inputs like email addresses, URLs, and file paths.
  • Use allowlists rather than denylists. Only accept known-good values rather than trying to block known-bad ones.

Security Checklist

Before deploying an authenticated MCP server, verify each item:

CheckStatus
No hardcoded secrets in source codeRequired
Environment variables validated at startupRequired
API keys use constant-time comparisonRequired
OAuth tokens validated with the authorization serverRequired for OAuth
Per-tool permission checks implementedRecommended
Input validation on all tool parametersRequired
Failed auth attempts logged with client detailsRecommended
Secrets rotated on a regular scheduleRecommended
.env files excluded from version controlRequired
HTTPS enforced for all remote connectionsRequired

What to Read Next