← Back to Blog
2025-01-28

Simple MCP Authentication for Your AI Side Project

Skip the enterprise complexity. Secure your Model Context Protocol server with practical authentication that works for indie hackers and small teams building AI features.

Simple MCP Authentication for Your AI Side Project

You're building something cool with AI—maybe an LLM-powered feature in your SaaS app or a custom AI assistant that accesses your data. You've discovered the Model Context Protocol (MCP) and you're excited about the possibilities.

Then you start researching MCP authentication, and suddenly you're drowning in whitepapers about "Agentic Identity Hubs" and "OAuth flows for AI agents" with price tags starting at $5,000 per month.

Here's the truth: MCP authentication for most side projects and small teams doesn't need to be that complicated.

Enterprise vendors are making AI auth seem like rocket science because they're selling to Fortune 500 companies with complex requirements. But if you're an indie hacker or small team building an AI-powered feature, you probably need something much simpler.

Let's build practical MCP authentication that actually makes sense for your project.

Quick Start Checklist

Before diving in, here's what you'll build in this guide:

  • [ ] Basic MCP server with Express (or your preferred framework)
  • [ ] API key authentication middleware (30 minutes)
  • [ ] User-scoped permissions so agents can't access other users' data (20 minutes)
  • [ ] Audit logging for debugging and security (15 minutes)
  • [ ] Key rotation endpoint for security best practices (15 minutes)
  • [ ] Rate limiting to prevent abuse (10 minutes)

Total time investment: ~2 hours to production-ready MCP auth

What you'll need:

  • Existing user authentication system (any standard auth solution)
  • Database with users table
  • Node.js/TypeScript environment (or equivalent in your language)
  • Basic understanding of API authentication

What is MCP? (Quick Primer)

The Model Context Protocol is a new standard introduced in late 2024 that lets AI models securely access external data and tools. Think of it as a standardized API that allows AI assistants to connect to your application's data—without users copy-pasting everything into ChatGPT.

Practical examples:

  • A customer support AI that can look up order history from your database
  • A coding assistant that can read your project's codebase and configuration
  • A research assistant that can search your company's document library
  • A data analyst AI that can query your analytics platform

Why authentication matters:

Your MCP server is giving an AI agent access to real data. You need to ensure:

  1. Only authorized users' AI agents can connect
  2. Each agent can only access data the user has permission to see
  3. You can audit what the AI agent accessed (for debugging and compliance)

This is where authentication becomes critical.

The Difference Between Human Auth and Agent Auth

Understanding this distinction is key to implementing MCP auth correctly.

When a human logs into your app:

  • They enter credentials in a browser
  • They receive a session cookie
  • They make requests directly to your API
  • Session expires after a few hours of inactivity

When an AI agent accesses your MCP server:

  • The human user authorizes the AI agent (one-time setup)
  • The agent receives credentials (like an API key)
  • The agent makes requests on behalf of the user (many times)
  • Credentials remain valid until explicitly revoked

You're not authenticating the AI itself—you're authenticating that this AI agent is authorized to act on behalf of a specific user. The agent is essentially a proxy for the user's identity and permissions.

Enterprise vs. Side Project Approach

Let's be honest about what different solutions offer and what you probably actually need.

Comparison Table: Enterprise vs. Simple MCP Auth

FeatureEnterprise Solutions ($5k+/mo)Simple Approach (DIY)Do You Need It?
API key authentication✅ Included✅ Easy to build (30 min)Yes - Core security
User-scoped permissions✅ Included✅ Easy to build (20 min)Yes - Prevent data leakage
Audit logging✅ Advanced analytics✅ Basic logs (15 min)Yes - For debugging
OAuth for third-party agents✅ Full OAuth 2.0 flows❌ Complex to build⚠️ Only if building a platform
Multi-tenant coordination✅ Enterprise-grade❌ Not needed⚠️ Only for org-level agents
Compliance certifications✅ SOC 2, HIPAA, etc.❌ DIY compliance⚠️ Only for regulated industries
Agent lifecycle management✅ Creation, rotation, hierarchy⚠️ Basic rotation (15 min)⚠️ Basic rotation is enough
Centralized policy engine✅ Complex rules❌ Not needed❌ Overkill for most projects
Agent-to-agent auth✅ Multiple AI vendors❌ Not needed❌ Only for agent marketplaces
Setup time2-4 weeks integration~2 hours-
Monthly cost$5,000 - $50,000+$0 (DIY) + hosting-

What Enterprise Vendors Are Selling

If you read the marketing pages from Stytch, WorkOS, Descope, or Clerk, you'll see terms like:

  • "Agentic Identity Hubs" with centralized policy engines
  • Complex OAuth flows specifically for AI-to-AI authentication
  • Enterprise-grade agent lifecycle management with hierarchical permissions
  • Multi-tenant agent coordination across different AI vendors
  • Pricing that starts at thousands per month with per-agent fees

Who this is for: Companies building AI agent platforms where multiple third-party AI agents need to interact securely. Think: enterprise workflow tools where AI agents from different vendors need coordinated access to shared resources with complex permission models.

What Your Side Project Probably Needs

If you're building a feature where YOUR AI accesses YOUR data on behalf of YOUR users, you need something much simpler:

  • API key authentication to secure your MCP server endpoints
  • Basic scoping so agents can only access data belonging to their associated user
  • Audit logging to see what the agent did (mostly for debugging and troubleshooting)
  • Connection to user identity so you know exactly who's making requests

That's it. No complex OAuth flows, no agent lifecycle management, no identity federation, no policy engines.

Simple MCP Auth Architecture

Here's the basic architecture for practical MCP authentication that actually works:

┌──────────────┐      ┌──────────────────┐      ┌─────────────────┐      ┌──────────┐
│   User in    │─────▶│  Your Web App    │◀────▶│   MCP Server    │◀────▶│   LLM    │
│   Browser    │      │  (with session)  │      │  (API key auth) │      │ (OpenAI) │
└──────────────┘      └──────────────────┘      └─────────────────┘      └──────────┘
                              │                          │
                              │                          │
                              ▼                          ▼
                      ┌────────────────┐        ┌────────────────┐
                      │   User Auth    │        │  Data Access   │
                      │   (Session)    │        │   (Scoped by   │
                      │                │        │    User ID)    │
                      └────────────────┘        └────────────────┘

What's happening in this flow:

  1. User authenticates to your app using standard login (username/password, OAuth, whatever you already have)
  2. Your app generates an API key associated with that specific user's account
  3. Your app connects to your MCP server using that user-scoped API key
  4. MCP server validates the key and enforces user-specific data access permissions
  5. MCP server logs what data was accessed for audit trails
  6. LLM receives context through the MCP protocol with proper authorization

What we're protecting against:

  • ✅ Unauthorized access to the MCP server (API key required for all requests)
  • ✅ Cross-user data leakage (API keys are scoped to specific users, can't access others' data)
  • ✅ Unaudited access (all requests are logged with user ID, timestamp, and action)
  • ✅ Stolen keys (can be rotated, rate-limited, and scoped with minimal permissions)

Implementation: Basic MCP Auth

Let's build this step by step. We'll use Node.js/TypeScript for examples, but the concepts apply to any language.

Step 1: Securing Your MCP Server

First, create middleware to validate API keys on every request:

// mcp-server/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { validateApiKey, getUserFromApiKey } from './api-keys';

/**
 * Middleware to require API key authentication on MCP endpoints.
 * Attaches the authenticated user to req.user for downstream handlers.
 */
export async function requireMcpAuth(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Extract API key from request header
  const apiKey = req.headers['x-api-key'];

  // Reject requests without API key
  if (!apiKey || typeof apiKey !== 'string') {
    return res.status(401).json({
      error: 'API key required',
      message: 'Include X-API-Key header with your API key'
    });
  }

  try {
    // Validate the key and get the associated user account
    const user = await getUserFromApiKey(apiKey);

    if (!user) {
      return res.status(401).json({
        error: 'Invalid API key',
        message: 'API key not found or has been revoked'
      });
    }

    // Check if user account is still active
    if (!user.isActive) {
      return res.status(403).json({
        error: 'Account inactive',
        message: 'User account has been deactivated'
      });
    }

    // Attach user to request object for use in route handlers
    req.user = user;
    next();
  } catch (error) {
    console.error('Auth middleware error:', error);
    return res.status(500).json({
      error: 'Authentication failed',
      message: 'Internal server error during authentication'
    });
  }
}

Apply this middleware to protect your MCP endpoints:

// mcp-server/server.ts
import express from 'express';
import { requireMcpAuth } from './middleware/auth';
import { handleMcpRequest } from './handlers';

const app = express();
app.use(express.json());

// Health check endpoint (no auth required - for monitoring)
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// All MCP endpoints require authentication
app.use('/mcp', requireMcpAuth);
app.post('/mcp/*', handleMcpRequest);

const PORT = process.env.MCP_PORT || 3001;
app.listen(PORT, () => {
  console.log(`MCP server running on port ${PORT}`);
  console.log(`Health check: http://localhost:${PORT}/health`);
});

Step 2: Connecting to User Identity

Now implement the API key system that links to your user authentication:

// mcp-server/api-keys.ts
import crypto from 'crypto';
import { db } from './database'; // Your database client (Prisma, Knex, etc.)

interface ApiKey {
  id: string;
  userId: string;
  keyHash: string; // Never store raw keys!
  scopes: string[];
  createdAt: Date;
  lastUsedAt: Date | null;
  expiresAt: Date | null; // Optional: set expiration dates
}

/**
 * Generate a new API key for a user with specified permissions.
 * Returns the raw key (only shown once - must be saved by user).
 */
export async function createApiKey(
  userId: string,
  scopes: string[] = ['read']
): Promise<string> {
  // Generate a cryptographically secure random key
  // Prefix helps identify the key type (useful for debugging)
  const apiKey = `mcp_${crypto.randomBytes(32).toString('hex')}`;

  // Hash the key before storing (NEVER store raw keys in database!)
  // If database is compromised, attackers can't use the keys
  const keyHash = crypto
    .createHash('sha256')
    .update(apiKey)
    .digest('hex');

  // Store hashed key in database
  await db.apiKeys.create({
    userId,
    keyHash,
    scopes,
    createdAt: new Date(),
    lastUsedAt: null,
    expiresAt: null, // Or set to Date.now() + 90 days for auto-expiry
  });

  // Return the raw key ONLY ONCE
  // User must save it securely - we can't retrieve it later
  return apiKey;
}

/**
 * Validate an API key and return the associated user.
 * Updates lastUsedAt timestamp for monitoring inactive keys.
 */
export async function getUserFromApiKey(
  apiKey: string
): Promise<{ id: string; email: string; scopes: string[]; isActive: boolean } | null> {
  // Hash the provided key to compare with stored hash
  const keyHash = crypto
    .createHash('sha256')
    .update(apiKey)
    .digest('hex');

  // Look up the key record and join with user table
  const record = await db.apiKeys.findOne({
    where: { keyHash },
    include: ['user'], // Join with users table to get user details
  });

  if (!record) {
    return null;
  }

  // Check if key has expired
  if (record.expiresAt && record.expiresAt < new Date()) {
    return null;
  }

  // Update last used timestamp (helps identify inactive keys for cleanup)
  await db.apiKeys.update(record.id, {
    lastUsedAt: new Date()
  });

  return {
    id: record.user.id,
    email: record.user.email,
    scopes: record.scopes,
    isActive: record.user.isActive,
  };
}

/**
 * Check if a user has a specific permission scope.
 * Admin scope grants access to everything.
 */
export function hasScope(
  user: { scopes: string[] },
  requiredScope: string
): boolean {
  return user.scopes.includes(requiredScope) ||
         user.scopes.includes('admin');
}

/**
 * Revoke all API keys for a user (used during key rotation).
 */
export async function revokeApiKeysForUser(userId: string): Promise<void> {
  await db.apiKeys.deleteMany({
    where: { userId }
  });
}

Add an endpoint in your main app for users to generate API keys:

// main-app/routes/api-keys.ts
import { createApiKey } from '../mcp-server/api-keys';

/**
 * Generate a new MCP API key for the authenticated user.
 * User must already be authenticated via your standard auth system.
 */
app.post('/api/mcp/keys', requireAuth, async (req, res) => {
  // Get user ID from your existing auth session
  const userId = req.session.userId;

  // Generate new API key with read/write permissions
  const apiKey = await createApiKey(userId, ['read', 'write']);

  res.json({
    apiKey,
    message: 'Save this key securely - you won\'t see it again!',
    expiresAt: null, // Or include expiration date if you set one
  });
});

/**
 * List existing API keys for the user (without showing the actual keys).
 */
app.get('/api/mcp/keys', requireAuth, async (req, res) => {
  const userId = req.session.userId;

  const keys = await db.apiKeys.findMany({
    where: { userId },
    select: ['id', 'createdAt', 'lastUsedAt', 'scopes'],
  });

  res.json({ keys });
});

Step 3: Audit Logging

Implement simple logging for debugging, security monitoring, and compliance:

// mcp-server/audit.ts
import { db } from './database';

interface AuditLog {
  userId: string;
  action: string; // 'read', 'write', 'delete', 'search', etc.
  resource: string; // 'document:123', 'user:profile', etc.
  metadata?: Record<string, any>; // Additional context (query params, etc.)
  ipAddress?: string; // Track request source
  userAgent?: string; // Track client information
}

/**
 * Log an MCP server access event for audit trail.
 * Useful for debugging, security monitoring, and compliance.
 */
export async function logMcpAccess(log: AuditLog): Promise<void> {
  await db.auditLogs.create({
    ...log,
    timestamp: new Date(),
  });

  // Also log to console in development for immediate visibility
  if (process.env.NODE_ENV === 'development') {
    console.log('[MCP Audit]', {
      time: new Date().toISOString(),
      user: log.userId,
      action: log.action,
      resource: log.resource,
    });
  }

  // Optionally: send to external monitoring service (DataDog, Sentry, etc.)
  // await monitoringService.track('mcp_access', log);
}

/**
 * Example: Use audit logging in your MCP request handlers
 */
export async function handleGetDocument(req: Request, res: Response) {
  const { user } = req; // From auth middleware
  const { documentId } = req.params;

  // Log the access attempt
  await logMcpAccess({
    userId: user.id,
    action: 'read',
    resource: `document:${documentId}`,
    metadata: { documentId },
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'],
  });

  // Fetch and return the document (only if user has permission)
  const document = await getDocument(documentId, user.id);

  if (!document) {
    return res.status(404).json({ error: 'Document not found or access denied' });
  }

  res.json(document);
}

Complete Working Example

Here's a full MCP server with authentication, ready to deploy:

// mcp-server/index.ts
import express from 'express';
import { requireMcpAuth } from './middleware/auth';
import { logMcpAccess } from './audit';
import { hasScope } from './api-keys';

const app = express();
app.use(express.json());

// Health check endpoint (no auth required - for uptime monitoring)
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    version: '1.0.0',
    timestamp: new Date().toISOString()
  });
});

// Apply authentication to all MCP endpoints
app.use('/mcp', requireMcpAuth);

/**
 * MCP Endpoint: Search user's documents
 * Returns documents matching the search query, scoped to the authenticated user.
 */
app.post('/mcp/search', async (req, res) => {
  const { query } = req.body;
  const { user } = req; // Injected by auth middleware

  // Validate input
  if (!query || typeof query !== 'string') {
    return res.status(400).json({
      error: 'Query required',
      message: 'Provide a search query in the request body'
    });
  }

  // Check permissions
  if (!hasScope(user, 'read')) {
    return res.status(403).json({
      error: 'Insufficient permissions',
      message: 'Your API key does not have read access'
    });
  }

  // Log the search request
  await logMcpAccess({
    userId: user.id,
    action: 'search',
    resource: 'documents',
    metadata: { query },
  });

  // Execute search (only returns documents belonging to this user)
  const results = await searchDocuments(query, user.id);

  res.json({
    results,
    count: results.length,
    query,
  });
});

/**
 * MCP Endpoint: Get specific document by ID
 * Returns full document content if user has permission.
 */
app.get('/mcp/documents/:id', async (req, res) => {
  const { id } = req.params;
  const { user } = req;

  // Check permissions
  if (!hasScope(user, 'read')) {
    return res.status(403).json({
      error: 'Insufficient permissions',
      message: 'Your API key does not have read access'
    });
  }

  // Log the document access
  await logMcpAccess({
    userId: user.id,
    action: 'read',
    resource: `document:${id}`,
    metadata: { documentId: id },
  });

  // Fetch document (getDocument enforces user ownership)
  const document = await getDocument(id, user.id);

  if (!document) {
    return res.status(404).json({
      error: 'Document not found',
      message: 'Document does not exist or you do not have access'
    });
  }

  res.json(document);
});

/**
 * MCP Endpoint: Create a new document
 * Requires write permissions.
 */
app.post('/mcp/documents', async (req, res) => {
  const { title, content } = req.body;
  const { user } = req;

  // Check write permissions
  if (!hasScope(user, 'write')) {
    return res.status(403).json({
      error: 'Insufficient permissions',
      message: 'Your API key does not have write access'
    });
  }

  // Validate input
  if (!title || !content) {
    return res.status(400).json({
      error: 'Missing required fields',
      message: 'Both title and content are required'
    });
  }

  // Create document owned by this user
  const document = await createDocument({
    title,
    content,
    userId: user.id,
  });

  // Log the creation
  await logMcpAccess({
    userId: user.id,
    action: 'create',
    resource: `document:${document.id}`,
    metadata: { documentId: document.id, title },
  });

  res.status(201).json(document);
});

const PORT = process.env.MCP_PORT || 3001;
app.listen(PORT, () => {
  console.log(`MCP server running on port ${PORT}`);
  console.log(`Health: http://localhost:${PORT}/health`);
  console.log(`MCP endpoints: http://localhost:${PORT}/mcp/*`);
});

Using the MCP server from your main application:

// main-app/services/ai.ts
import { OpenAI } from 'openai';

/**
 * Query the LLM with context from the user's documents via MCP server.
 */
async function queryWithContext(
  userQuery: string,
  userId: string
): Promise<string> {
  // Get the user's API key for the MCP server
  // This should be stored securely (encrypted in your database)
  const apiKey = await getApiKeyForUser(userId);

  if (!apiKey) {
    throw new Error('User has not set up MCP access');
  }

  // Fetch relevant context from MCP server
  const contextResponse = await fetch('http://localhost:3001/mcp/search', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': apiKey, // User-scoped authentication
    },
    body: JSON.stringify({ query: userQuery }),
  });

  if (!contextResponse.ok) {
    throw new Error(`MCP server error: ${contextResponse.status}`);
  }

  const { results } = await contextResponse.json();

  // Call the LLM with the retrieved context
  const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
  const completion = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [
      {
        role: 'system',
        content: 'You are a helpful assistant. Use the provided context from the user\'s documents to answer questions accurately. If the context doesn\'t contain relevant information, say so.',
      },
      {
        role: 'user',
        content: `Context from user's documents:\n${JSON.stringify(results, null, 2)}\n\nUser question: ${userQuery}`,
      },
    ],
  });

  return completion.choices[0].message.content || 'No response generated';
}

Scope Permissions Reference

Understanding and implementing proper scopes is crucial for security. Here's a comprehensive reference:

Standard Scope Definitions

ScopePermissionsUse CaseRisk Level
readView documents, search, listAI assistants answering questionsLow - Can't modify data
writeCreate, update documentsAI assistants creating contentMedium - Can create data
deleteRemove documentsAdmin AI toolsHigh - Destructive operations
adminAll permissionsFull system accessCritical - Use sparingly

Scope Implementation Example

// Define your scope hierarchy
const SCOPES = {
  read: ['get', 'list', 'search'],
  write: ['create', 'update'],
  delete: ['delete'],
  admin: ['*'], // Grants all permissions
};

// Check if user's scopes allow a specific action
function canPerformAction(
  userScopes: string[],
  requiredAction: string
): boolean {
  // Admin scope grants everything
  if (userScopes.includes('admin')) {
    return true;
  }

  // Check each user scope to see if it includes the required action
  return userScopes.some(scope => {
    const allowedActions = SCOPES[scope] || [];
    return allowedActions.includes(requiredAction) || allowedActions.includes('*');
  });
}

Best Practices for Scopes

  1. Start with minimal permissions: Default to read only. Add write permissions only when needed.
  2. Use specific scopes: Instead of admin, create specific scopes like documents:read, documents:write.
  3. Document your scopes: Clearly explain what each scope allows in your API documentation.
  4. Allow users to view scopes: Show users what permissions their API keys have.
  5. Audit scope usage: Log when elevated permissions are used.

Security Considerations (Don't Skip This)

Even with simple MCP auth, following these security practices is essential:

Security Checklist

Security MeasurePriorityImplementation TimeWhy It Matters
Hash API keys in database✅ Critical5 minStolen database can't be used to access your system
Use HTTPS in production✅ Critical15 minPrevents API keys from being intercepted
Implement rate limiting✅ Critical10 minPrevents abuse and DoS attacks
Allow key rotation✅ High15 minLets users respond to key compromise
Log all access✅ High10 minEnables security audits and debugging
Use minimal scopes✅ High5 minLimits damage from compromised keys
Set key expiration⚠️ Medium10 minForces periodic key rotation
Monitor for anomalies⚠️ Medium30 minDetects suspicious access patterns
Implement IP whitelisting⚠️ Low20 minOptional extra security layer

API Key Rotation

Allow users to rotate their API keys without disrupting service:

/**
 * Rotate API key for the authenticated user.
 * Revokes old keys and generates a new one.
 */
app.post('/api/mcp/keys/rotate', requireAuth, async (req, res) => {
  const userId = req.session.userId;

  // Revoke all existing keys for this user
  await revokeApiKeysForUser(userId);

  // Generate a new key with the same permissions
  const newKey = await createApiKey(userId, ['read', 'write']);

  // Notify user via email about key rotation
  await sendEmail(user.email, {
    subject: 'MCP API Key Rotated',
    body: 'Your MCP API key has been rotated. Update your configuration with the new key.',
  });

  res.json({
    apiKey: newKey,
    message: 'Old keys have been revoked. Update your MCP configuration with this new key.',
  });
});

Rate Limiting is Critical

AI agents can make many requests quickly. Implement rate limiting to prevent abuse:

import rateLimit from 'express-rate-limit';

/**
 * Rate limiter for MCP endpoints.
 * Limits to 100 requests per minute per API key.
 */
const mcpLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute window
  max: 100, // 100 requests per window per key
  keyGenerator: (req) => req.user.id, // Rate limit per user, not per IP
  message: {
    error: 'Rate limit exceeded',
    message: 'Too many MCP requests. Please try again in a minute.',
  },
  standardHeaders: true, // Include rate limit info in headers
  legacyHeaders: false,
});

// Apply rate limiter to all MCP endpoints
app.use('/mcp', mcpLimiter);

Recommended rate limits:

  • Development: 100 requests/minute (allows rapid testing)
  • Production free tier: 60 requests/minute (1 per second)
  • Production paid tier: 300+ requests/minute (5+ per second)

What AI Agents Should Never Access

Even with proper authentication, some data should be explicitly excluded:

Never expose to AI agents:

  • ❌ Raw user credentials or password hashes
  • ❌ Other users' personal information (enforce strict user scoping)
  • ❌ Internal system configurations or environment variables
  • ❌ Rate limit or abuse detection data (prevents circumvention)
  • ❌ Other users' API keys or session tokens
  • ❌ Payment information (credit cards, bank accounts)
  • ❌ Raw audit logs (could reveal security vulnerabilities)

Implement an explicit blocklist in your data access layer:

// Blocked resource patterns that MCP should never access
const BLOCKED_RESOURCES = [
  /^user:.*:password$/,
  /^user:.*:api_key$/,
  /^system:config/,
  /^payment:/,
  /^audit_log:/,
];

function isResourceBlocked(resourceId: string): boolean {
  return BLOCKED_RESOURCES.some(pattern => pattern.test(resourceId));
}

Common Mistakes to Avoid

🚫 Mistake #1: Storing Raw API Keys

Wrong:

// Never do this!
await db.apiKeys.create({
  userId,
  apiKey: rawKey, // Stored in plain text
});

Right:

// Always hash keys before storing
const keyHash = crypto
  .createHash('sha256')
  .update(rawKey)
  .digest('hex');

await db.apiKeys.create({
  userId,
  keyHash, // Only store the hash
});

Why: If your database is compromised, attackers immediately have working API keys.

🚫 Mistake #2: Not Validating User Ownership

Wrong:

// Returns document regardless of ownership!
app.get('/mcp/documents/:id', requireMcpAuth, async (req, res) => {
  const document = await db.documents.findOne(req.params.id);
  res.json(document);
});

Right:

// Enforces user ownership
app.get('/mcp/documents/:id', requireMcpAuth, async (req, res) => {
  const document = await db.documents.findOne({
    where: {
      id: req.params.id,
      userId: req.user.id, // Critical: check ownership!
    }
  });

  if (!document) {
    return res.status(404).json({ error: 'Document not found' });
  }

  res.json(document);
});

Why: Without ownership checks, users can access other users' data by guessing IDs.

🚫 Mistake #3: No Rate Limiting

Wrong:

// AI agent can make unlimited requests
app.post('/mcp/search', requireMcpAuth, handleSearch);

Right:

// Enforce rate limits
const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  keyGenerator: (req) => req.user.id,
});

app.post('/mcp/search', limiter, requireMcpAuth, handleSearch);

Why: AI agents can make requests very quickly, potentially overloading your server or running up LLM costs.

🚫 Mistake #4: Overly Broad Scopes

Wrong:

// Every key gets admin access
const apiKey = await createApiKey(userId, ['admin']);

Right:

// Start with minimal permissions
const apiKey = await createApiKey(userId, ['read']);
// Only add write if specifically needed

Why: Principle of least privilege—if a key is compromised, damage is limited.

🚫 Mistake #5: No Audit Logging

Wrong:

// No record of what the AI agent accessed
app.get('/mcp/documents/:id', requireMcpAuth, async (req, res) => {
  const document = await getDocument(req.params.id, req.user.id);
  res.json(document);
});

Right:

// Log every access for debugging and security
app.get('/mcp/documents/:id', requireMcpAuth, async (req, res) => {
  await logMcpAccess({
    userId: req.user.id,
    action: 'read',
    resource: `document:${req.params.id}`,
  });

  const document = await getDocument(req.params.id, req.user.id);
  res.json(document);
});

Why: When something goes wrong (and it will), you need to know what the AI agent did.

Troubleshooting Guide

Issue: "Invalid API Key" errors

Possible causes:

  • Key is being stored incorrectly (make sure you're hashing consistently)
  • Key has been revoked or expired
  • Wrong header name (should be X-API-Key)
  • Key prefix is wrong (e.g., mcp_ prefix missing)

Debug steps:

// Add detailed logging to auth middleware
console.log('Received header:', req.headers['x-api-key']);
console.log('Computed hash:', keyHash);
console.log('Found record:', record ? 'yes' : 'no');

Issue: AI agent accessing wrong user's data

Possible causes:

  • Not checking user ownership in queries
  • Using wrong user ID in scoping
  • Caching issues with user context

Fix:

// Always include userId in database queries
const documents = await db.documents.findMany({
  where: {
    userId: req.user.id, // Critical: scope to authenticated user
  }
});

Issue: Rate limit errors even with normal usage

Possible causes:

  • Rate limit is too strict for your use case
  • Multiple agents sharing the same API key
  • Background processes making requests

Fix:

// Increase limits or implement burst allowance
const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 200, // Increased from 100
  skipSuccessfulRequests: false,
});

Issue: Slow MCP responses

Possible causes:

  • Database queries not optimized
  • No database indexes on userId fields
  • Audit logging is synchronous

Fix:

// Make audit logging asynchronous (fire-and-forget)
logMcpAccess(auditData).catch(err => {
  console.error('Audit log failed:', err);
  // Don't fail the request if logging fails
});

// Add database index
CREATE INDEX idx_documents_userid ON documents(userId);

Real Example: AI-Powered Document Search

Let's see this in action with a practical, production-ready example.

What you're building:

Users can ask natural language questions about their documents. The AI assistant searches their document library and provides relevant answers with citations.

Complete Authentication Flow:

  1. User logs into your app with standard authentication (email/password, OAuth, etc.)
  2. User visits the AI Assistant page in your app
  3. Your backend automatically generates an MCP API key for this user (if they don't have one)
  4. User asks: "What were our Q3 revenue numbers?"
  5. Your frontend sends the query to your backend API
  6. Backend calls your MCP server with the user's scoped API key
  7. MCP server searches only documents belonging to that user
  8. MCP server returns relevant document excerpts with metadata
  9. Backend sends context to OpenAI with proper formatting
  10. User gets answer: "According to your Q3 Financial Report (uploaded Oct 15), revenue was $125,000..."

Complete Code Implementation:

// Frontend: AI Assistant Component
// src/components/AiAssistant.tsx
import { useState } from 'react';

function AiAssistant() {
  const [query, setQuery] = useState('');
  const [response, setResponse] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);
    setError(null);

    try {
      const result = await fetch('/api/ai/query', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query }),
        credentials: 'include', // Include session cookie
      });

      if (!result.ok) {
        throw new Error('Failed to get response');
      }

      const { answer } = await result.json();
      setResponse(answer);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div className="ai-assistant">
      <h2>AI Document Assistant</h2>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Ask a question about your documents..."
          disabled={isLoading}
        />
        <button type="submit" disabled={isLoading || !query}>
          {isLoading ? 'Searching...' : 'Ask AI'}
        </button>
      </form>

      {error && (
        <div className="error">
          Error: {error}
        </div>
      )}

      {response && (
        <div className="response">
          <strong>AI Answer:</strong>
          <p>{response}</p>
        </div>
      )}
    </div>
  );
}

export default AiAssistant;
// Backend: AI Query Endpoint
// routes/ai.ts
import { OpenAI } from 'openai';
import { requireAuth } from '../middleware/auth';
import { getApiKeyForUser, createApiKey } from '../services/api-keys';

app.post('/api/ai/query', requireAuth, async (req, res) => {
  const { query } = req.body;
  const userId = req.session.userId; // From your auth system

  // Validate input
  if (!query || typeof query !== 'string') {
    return res.status(400).json({
      error: 'Query required'
    });
  }

  try {
    // Get or create API key for this user
    let apiKey = await getApiKeyForUser(userId);
    if (!apiKey) {
      // Auto-generate API key on first AI query
      apiKey = await createApiKey(userId, ['read']);
    }

    // Query MCP server with user's scoped API key
    const contextResponse = await fetch('http://localhost:3001/mcp/search', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': apiKey, // User-specific authentication
      },
      body: JSON.stringify({
        query,
        limit: 5, // Return top 5 most relevant documents
      }),
    });

    if (!contextResponse.ok) {
      throw new Error(`MCP server error: ${contextResponse.status}`);
    }

    const { results } = await contextResponse.json();

    // Format context for the LLM
    const contextText = results
      .map((doc: any) =>
        `Document: ${doc.title}\nContent: ${doc.excerpt}\nSource: ${doc.filename}`
      )
      .join('\n\n---\n\n');

    // Call OpenAI with retrieved context
    const openai = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY
    });

    const completion = await openai.chat.completions.create({
      model: 'gpt-4',
      messages: [
        {
          role: 'system',
          content: 'You are a helpful assistant that answers questions based on the user\'s documents. Always cite which document you got information from. If you don\'t find relevant information in the provided context, say so clearly.',
        },
        {
          role: 'user',
          content: `Context from user's documents:\n\n${contextText}\n\nUser question: ${query}`,
        },
      ],
      temperature: 0.7,
    });

    const answer = completion.choices[0].message.content;

    res.json({
      answer,
      sources: results.map((doc: any) => ({
        title: doc.title,
        excerpt: doc.excerpt,
      })),
    });

  } catch (error) {
    console.error('AI query error:', error);
    res.status(500).json({
      error: 'Failed to process AI query',
      message: error instanceof Error ? error.message : 'Unknown error'
    });
  }
});

Security Enforcement:

The MCP server only returns documents belonging to the user associated with the API key. Even if an attacker obtains an API key, they can only access that one user's documents—not the entire database.

// MCP Server: Scoped Document Search
// mcp-server/handlers/search.ts
async function searchDocuments(
  query: string,
  userId: string,
  limit: number = 10
) {
  // This query ONLY returns documents owned by the authenticated user
  const documents = await db.documents.findMany({
    where: {
      userId: userId, // Critical: scope to authenticated user
      // Optional: add full-text search
      OR: [
        { title: { contains: query, mode: 'insensitive' } },
        { content: { contains: query, mode: 'insensitive' } },
      ],
    },
    select: {
      id: true,
      title: true,
      excerpt: true,
      filename: true,
      createdAt: true,
    },
    take: limit,
    orderBy: {
      // Optional: implement relevance scoring
      createdAt: 'desc',
    },
  });

  return documents;
}

When You Need to Upgrade

The simple approach in this guide works great for most projects. Here's when you might need enterprise-grade solutions:

Decision Matrix: Should You Upgrade?

Your SituationSimple Approach WorksConsider EnterpriseReasoning
Single app, your own AI agents✅ Yes❌ NoBasic API keys are sufficient
Multiple third-party AI agents⚠️ Maybe✅ YesNeed OAuth-style delegation
Organization-level agent sharing⚠️ Maybe✅ YesComplex permission hierarchies
Regulated industry (healthcare, finance)❌ No✅ YesCompliance certifications required
<1000 users✅ Yes❌ NoDon't over-engineer
1000-10,000 users✅ Yes⚠️ MaybeMonitor and decide
>10,000 users⚠️ Maybe✅ YesMay need advanced features
Development budget <$1000/mo✅ Yes❌ NoEnterprise costs too high
Compliance requirements (SOC 2, HIPAA)❌ No✅ YesCertifications matter

You're Building a Platform for Third-Party AI Agents

If external AI agents (not just yours) need to access your users' data, you need OAuth-style flows.

Example: A Zapier-like service where various AI agents from different vendors interact with user accounts.

Then consider: WorkOS MCP Auth, Clerk's Agent Identity, or implementing OAuth 2.0 yourself with libraries like oauth2-server.

You Need Multi-Tenant AI Access

If you have organization accounts where multiple AI agents from different vendors need coordinated access to shared resources with complex permission models.

Example: Enterprise workflow platform where different departments' AI agents share access to company data with role-based permissions.

Then consider: Stytch MCP Auth, Descope's Agentic Identity Hub.

You Need Compliance Certifications

If you're in healthcare, finance, or other regulated industries where AI access needs to meet SOC 2 Type II, HIPAA, or similar compliance requirements.

Example: Healthcare app where AI agents access patient records (requires HIPAA compliance).

Then consider: Enterprise-grade solutions with compliance certifications, plus legal review.

You Have Complex Agent Lifecycles

If you're managing fleets of AI agents with creation, rotation, revocation, and hierarchical permissions across multiple systems.

Example: AI agent marketplace where agents are dynamically created, granted permissions, and decommissioned.

Then consider: Building a custom identity platform or using enterprise agent management solutions.

For everyone else: Keep it simple. Start with API keys and basic scoping. Add complexity only when you actually need it. You can always migrate later if your requirements change.

What AuthHero Provides for MCP

We built AuthHero to make authentication simple for developers building modern applications—including AI-powered features.

Here's what we provide:

User Authentication: Handle the human authentication part with our standard auth system:

  • OAuth providers (Google, GitHub, etc.)
  • Email/password with secure hashing
  • Multi-factor authentication (MFA)
  • Session management
  • Password reset flows

API Key Management: [Describe if you have built-in API key generation and management features, or if you provide helpers/examples for building it]

  • Generate API keys programmatically via our API
  • Associate keys with specific users and permissions
  • Built-in key rotation endpoints
  • Usage analytics per API key

User Scoping: [Describe how your system helps developers scope API keys to specific users and permissions]

  • Automatic user ID association
  • Permission scope enforcement
  • Role-based access control integration
  • Organization-level scoping for team accounts

Audit Logging: [Describe any built-in audit log features or how you make it easy to add logging]

  • Pre-built audit log models and tables
  • Automatic event tracking for auth events
  • Queryable audit API for compliance reports
  • Retention policy configuration

Migration Path: Start simple with basic API key auth. As you scale, [describe upgrade path to more sophisticated features if applicable].

No "Agentic Identity Hub" pricing: We don't charge extra for AI use cases. If you authenticate 10,000 users in your app, and those users' AI agents make requests on their behalf, that's still just 10,000 users in your bill. No per-agent fees, no special AI pricing tier.

Developer-friendly: Complete code examples (like the ones in this guide), TypeScript SDK, and responsive support when you need help.

Conclusion: Start Building Today

MCP authentication doesn't have to be complicated. The enterprise solutions are building for Fortune 500 companies with complex agent ecosystems and multi-vendor coordination. But most side projects and small team applications need something much simpler and more practical.

All you really need:

  • ✅ API keys to authenticate requests
  • ✅ User scoping so agents can't access other users' data
  • ✅ Basic audit logging for debugging and security
  • ✅ Standard user authentication (which you probably already have)

That's it. You can build this in an afternoon and have secure AI integration running by dinner.

Don't let the enterprise complexity stop you from shipping your AI-powered feature. Start simple, ship fast, and add sophistication only when you actually need it. Your users don't care if you're using an "Agentic Identity Hub"—they care that your AI feature works securely and reliably.

The simple approach in this guide:

  • Takes ~2 hours to implement from scratch
  • Costs $0 (just your time and existing infrastructure)
  • Scales to thousands of users
  • Provides production-ready security
  • Is easier to debug and maintain than enterprise solutions

Ready to add auth to your MCP server this weekend?

[Your specific CTA - e.g., "Sign up for AuthHero's free tier—handle the user authentication part while you focus on building your AI features. No credit card required, and our API key management examples get you started in minutes."]

[Link to sign up / documentation]


Code repository: Find the complete working examples from this post at [link to GitHub repo with full code]

Related Reading:

  • [Link: API Authentication Best Practices] - [Link to your API authentication docs]
  • [Link: MCP Integration Guide] - [Link to any MCP-specific documentation you have]
  • [Link: When to Skip Enterprise Auth Features] - [Link to "Authentication Features You Can Skip" post if exists]
  • [Link: Rate Limiting Strategies for AI Applications] - [Link if exists]