intermediateUse-casePrimary12 min read

Adding Authentication to Your MCP Server

Overview

Adding Authentication to Your MCP Server When I first started working with Model Context Protocol (MCP) servers last year, I cut every corner I could to ship a proof of concept for my startup’s engineering team. Our goal was simple: let internal Claude Desktop

Key Concepts

  • **Local Validation**: For JWT tokens, you verify the token’s signature locally using your auth provider’s public signing key (usually served via a JWKS endpoint). This is fast—no external HTTP call is needed per request, so you keep latency low for your MCP clients. The downside is that you can’t revoke a token before it expires unless you maintain a separate blocklist of revoked tokens.
  • **Remote Validation**: You send the token to your auth provider’s introspection endpoint to check if it’s still valid. This lets you revoke tokens immediately, and all the state is managed by your auth provider. The downside is that it adds latency to every request (usually 5-20ms, depending on your provider) and your MCP availability becomes dependent on your auth provider’s availability.

When I first started working with Model Context Protocol (MCP) servers last year, I cut every corner I could to ship a proof of concept for my startup’s engineering team. Our goal was simple: let internal Claude Desktop instances pull context from our private Jira, Confluence, and production error logs to answer developer questions faster. I was in a hurry, so I hardcoded a test API key in the repo, skipped proper validation, and hosted the server on a public EC2 instance to let remote team members access it. I told myself, “It’s just an internal tool, no one will find it.”

Two weeks later, our security tooling flagged 1,200+ unexpected requests from an unknown IP address based in Eastern Europe. A public crawler had found our open endpoint, scraped 3 months of unannounced product roadmap, and the data ended up posted on a public Discord server for startup job seekers. We got lucky it wasn’t worse—no customer data was exposed, but we took a reputational hit and spent 3 full days cleaning up the mess, notifying our leadership, and auditing what other data the crawler could access. That mistake taught me that authentication isn’t just a “nice to have” for MCP servers: it’s a non-negotiable, even for internal tools.

If you’re new to MCP, it’s the fast-growing open standard for connecting large language models (LLMs) like Claude, ChatGPT, or Gemini to external data sources and tools. Unlike generic APIs, MCP is purpose-built to let AI models pull dynamic, granular context directly from your systems to answer questions or take actions—which means by design, your MCP server has access to almost all of your most sensitive business or user data. That makes authentication even more critical than it is for regular APIs: an unauthenticated MCP doesn’t just expose an endpoint, it exposes a complete map of your private data that any AI client or attacker can navigate automatically.

In this tutorial, I’ll walk you through adding authentication to your MCP server, starting with the simplest method for internal use, moving to secure OAuth for public third-party access, and covering all the supporting practices you need to keep your server secure. We’ll cover practical tradeoffs at every step, so you can choose the right approach for your use case, no matter if you’re building a small internal tool or a public MCP that thousands of users will connect to their AI clients.

API Key Authentication: The Simplest Starting Point

For internal MCP servers, or server-to-server connections where you only have a small number of static clients, API key authentication is the best place to start. It’s easy to implement, has minimal overhead, and requires almost no extra infrastructure. The core idea is simple: every client gets a unique, long-lived API key that it sends with every request to your MCP server. The server validates the key before processing the request.

I’ve heard a lot of engineers argue “API keys are insecure, you should always use OAuth” but that’s just not true for internal use cases. If you only have 5-10 clients (like individual developer workstations or internal CI/CD pipelines), the complexity of OAuth far outweighs any security benefit. That said, there are a lot of small mistakes that can make your API key auth useless, even if you have the basic flow set up.

Key Tradeoffs for API Key Auth

| Pros | Cons |

|------|------|

| 10 lines of code to implement | Long-lived keys, so a leak gives permanent access until rotation |

| No external dependencies | Hard to add granular permissions per client |

| Low latency for requests | Not ideal for multi-tenant or third-party use cases |

If you’re building an MCP for your team’s internal use, this tradeoff is almost always worth it. The biggest gotcha I ran into when I first added API key auth after our initial breach: I used plain old string comparison to check the incoming key against my stored key, like this: `if (requestKey === process.env.MCP_API_KEY) return true;` I thought that was fine, until our security lead pulled me aside and explained that regular string comparison is vulnerable to timing attacks. In a timing attack, an attacker measures how long it takes your server to reject an invalid key, and can guess the correct key one character at a time. Over hundreds of requests, they can reconstruct a full 32-character API key without any fancy exploits.

That’s why the example below uses Node’s built-in `crypto.timingSafeEqual` function, which takes the same amount of time to run no matter how many characters match, eliminating this entire class of attacks. It’s a one-line change that makes your auth drastically more secure, and it’s easy to miss if you’re in a hurry.

Below is a runnable example of an MCP server with API key auth built on Node.js and Express:

```javascript

require('dotenv').config();

const { McpServer } = require('@modelcontextprotocol/sdk/server');

const { ExpressHttpTransport } = require('@modelcontextprotocol/sdk/transports');

const express = require('express');

const crypto = require('crypto');

const app = express();

const PORT = process.env.PORT || 3000;

// Validate API key with timing-safe comparison to prevent timing attacks

function validateApiKey(requestKey) {

if (!process.env.MCP_API_KEY) return false;

const validKeyBuffer = Buffer.from(process.env.MCP_API_KEY);

const requestKeyBuffer = Buffer.from(requestKey);

if (validKeyBuffer.length !== requestKeyBuffer.length) {

return false;

}

return crypto.timingSafeEqual(validKeyBuffer, requestKeyBuffer);

}

// Auth middleware

function apiKeyAuthMiddleware(req, res, next) {

const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {

return res.status(401).json({ error: 'Unauthorized: Missing or invalid auth header' });

}

const requestKey = authHeader.split(' ')[1];

if (!validateApiKey(requestKey)) {

return res.status(401).json({ error: 'Unauthorized: Invalid API key' });

}

next();

}

// Initialize MCP server

const server = new McpServer({

name: 'my-internal-mcp',

version: '1.0.0',

});

// Example MCP resource: fetch internal product documentation

server.resource('product-doc', 'doc://{id}', async (request) => {

const docId = request.params.id;

// Add your logic to fetch doc content from your database here

return {

contents: [{ type: 'text', text: `Internal content for document ${docId}` }],

};

});

// Attach auth middleware to all MCP endpoints

app.use('/mcp', apiKeyAuthMiddleware);

const transport = new ExpressHttpTransport(server, app, '/mcp');

transport.start();

app.listen(PORT, () => {

console.log(`MCP server running on http://localhost:${PORT} with API key auth enabled`);

});

```

To run this, just install dependencies with `npm install @modelcontextprotocol/sdk express dotenv`, add your API key to a `.env` file, and start the server.

This example uses a single API key for all internal clients, which works great for small teams. But if you need to issue unique keys to individual developers or services (so you can revoke access for one person without breaking everyone else’s connection), you shouldn’t store plaintext keys in environment variables. Instead, store salted hashes of each key in a database, so even if your database is compromised, attackers can’t reverse-engineer the original keys to use them. Here’s a quick runnable example of how to validate multiple hashed API keys:

```javascript

const crypto = require('crypto');

// Each entry in your database stores the key ID, salt, and hashed key

// The client sends `Key-ID: <id>` and `Bearer <api-key>` in headers

async function validateMultipleApiKeys(keyId, requestKey, db) {

const storedKey = await db.apiKeys.findUnique({ where: { id: keyId } });

if (!storedKey || storedKey.revoked) return false;

// Hash the incoming request key with the stored salt

const hash = crypto.pbkdf2Sync(requestKey, storedKey.salt, 10000, 64, 'sha512').toString('hex');

// Timing-safe comparison still applies here

const hashBuffer = Buffer.from(hash);

const storedBuffer = Buffer.from(storedKey.hash);

if (hashBuffer.length !== storedBuffer.length) return false;

return crypto.timingSafeEqual(hashBuffer, storedBuffer);

}

```

This approach only adds a tiny bit of extra complexity, and it gives you per-key revocation that’s critical as your team grows. The only downside is that you need a database to store the keys, but that’s a tradeoff worth making for any internal MCP with more than 3 users. The biggest downside of API key auth overall is the long-lived nature of the keys. To mitigate this, you should always have a process for rotating keys: set a calendar reminder to rotate all API keys every 90 days, and keep an updated list of all issued keys so you can revoke immediately if an employee leaves or a device is lost.

OAuth 2.0 for Third-Party and Multi-Tenant Use Cases

If you’re building a public MCP server that third-party developers or end users will connect to their own AI clients (like a public MCP for Todoist or Google Drive), API keys aren’t enough. You need OAuth 2.0, which lets users grant limited, revocable access to their data without sharing their login credentials with third parties.

OAuth adds more moving parts, but it’s the industry standard for secure third-party access. The most common flow for MCP is the authorization code flow, where a user logs in, grants permission, and the client gets a short-lived access token that it uses for requests. One of the biggest questions people ask me about OAuth for MCP is whether they should roll their own OAuth server or use a managed provider like Clerk, Auth0, or Okta. That’s a critical tradeoff: rolling your own OAuth means you’re responsible for everything from secure token storage to handling password resets to mitigating CSRF attacks, and it’s very easy to miss a critical vulnerability. For 99% of public MCPs, using a managed OAuth provider is worth every penny of the cost. It cuts your development time from weeks to hours, and you get all the security patches and compliance out of the box. The example below works whether you’re rolling your own or using a managed provider that supports the authorization code flow.

Key Tradeoffs for OAuth

| Pros | Cons |

|------|------|

| Users can revoke access anytime | More complex to implement and test |

| Granular permission scopes | Adds minor latency to token validation |

| Supports unlimited users/clients | Requires extra infrastructure for token storage |

Again, I have a hard lesson I learned here to share. When I was building our first public MCP that let users connect their Jira accounts to Claude, I was in a hurry to launch for our beta waitlist. I remembered that OAuth requires a `state` parameter to prevent CSRF attacks, but I thought “it’s just a beta, there’s no way anyone will exploit this” so I hardcoded a static state value and skipped storing and validating the random state per request. A week after launching, we had a security incident: an attacker tricked 12 beta users into starting the auth flow, and the CSRF vulnerability let the attacker link those users’ Jira accounts to their own MCP connection. The attacker was able to read private project data from 6 users before we noticed and shut it down. We had to notify all beta users, ask everyone to re-authenticate, and delay our public launch by two weeks all because I skipped a 5-line security step.

Never skip the state check. Always generate a unique random state for each authorization request, store it in a secure, HttpOnly cookie or your database, and validate that the state returned from the authorization server matches the one you generated. It takes 5 extra lines of code, and it stops entire classes of attacks.

Below is a runnable example of an MCP server with OAuth 2.0 authentication:

```javascript

require('dotenv').config();

const express = require('express');

const crypto = require('crypto');

const { McpServer } = require('@modelcontextprotocol/sdk/server');

const { ExpressHttpTransport } = require('@modelcontextprotocol/sdk/transports');

const { authorizationCode } = require('simple-oauth2');

const app = express();

app.use(express.json());

const PORT = process.env.PORT || 3000;

// Configure OAuth client

const oauthClient = authorizationCode.create({

client: {

id: process.env.OAUTH_CLIENT_ID,

secret: process.env.OAUTH_CLIENT_SECRET,

},

auth: {

tokenHost: process.env.OAUTH_TOKEN_HOST,

authorizePath: process.env.OAUTH_AUTHORIZE_PATH,

tokenPath: process.env.OAUTH_TOKEN_PATH,

},

});

// OAuth middleware to validate access tokens on every request

async function oauthAuthMiddleware(req, res, next) {

const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {

return res.status(401).json({ error: 'Unauthorized: Missing access token' });

}

const accessToken = oauthClient.createToken({

access_token: authHeader.split(' ')[1],

});

try {

// Refresh expired tokens automatically

if (accessToken.expired()) {

const refreshedToken = await accessToken.refresh();

res.locals.accessToken = refreshedToken;

} else {

res.locals.accessToken = accessToken;

}

next();

} catch (error) {

return res.status(401).json({ error: 'Invalid or expired access token' });

}

}

// Authorization endpoint for third-party clients

app.get('/oauth/authorize', (req, res) => {

const redirectUri = process.env.OAUTH_REDIRECT_URI;

// Generate a random state to prevent CSRF attacks (store in your DB to validate later)

const state = crypto.randomUUID();

// Store state in a secure HttpOnly cookie for validation on callback

res.cookie('oauth_state', state, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });

const authorizationUri = oauthClient.authorizeURL({

redirect_uri: redirectUri,

scope: 'read:mcp-context',

state: state,

});

res.redirect(authorizationUri);

});

// Callback endpoint to exchange authorization code for access token

app.get('/oauth/callback', async (req, res) => {

// Validate state to prevent CSRF

const state = req.query.state;

if (state !== req.cookies.oauth_state) {

return res.status(400).json({ error: 'Invalid state parameter' });

}

const code = req.query.code;

const redirectUri = process.env.OAUTH_REDIRECT_URI;

try {

const token = await oauthClient.getToken({ code, redirect_uri: redirectUri });

res.json({

access_token: token.token.access_token,

expires_in: token.token.expires_in,

refresh_token: token.token.refresh_token,

});

} catch (error) {

res.status(400).json({ error: 'Failed to exchange code for access token' });

}

});

// Initialize MCP server

const server = new McpServer({ name: 'public-user-mcp', version: '1.0.0' });

// Example user-specific resource: only return data belonging to the authenticated user

server.resource('user-notes', 'notes://{id}', async (request) => {

const userId = res.locals.accessToken.claims().sub;

return {

contents: [{ type: 'text', text: `Note content for user ${userId}` }],

};

});

// Protect all MCP endpoints with OAuth

app.use('/mcp', oauthAuthMiddleware);

const transport = new ExpressHttpTransport(server, app, '/mcp');

transport.start();

app.listen(PORT, () => {

console.log(`MCP server running with OAuth 2.0 on port ${PORT}`);

});

```

Another thing to note for MCP OAuth: most AI clients that connect to MCP servers (like Claude Desktop) require that you follow the standard authorization code flow closely. They don’t handle custom redirects or non-standard token responses well, so stick to the spec as closely as possible to avoid confusing your users. It’s also important to use granular scopes: don’t ask for `read:all-data` when your MCP only needs access to read the user’s project tasks. Granular scopes give users more control over their data, and limit the damage if a token is ever compromised.

Storing Secrets Securely with Environment Variables

No matter what auth method you choose, you should never hardcode secrets like API keys or OAuth client secrets in your source code. That’s exactly what I did in my early mistake, and it’s one of the most common security flaws in MCP servers.

The standard practice is to store all secrets in environment variables, loaded from a `.env` file in development and injected by your deployment platform or secret manager in production. Add `.env` to your `.gitignore` file to ensure it never gets committed to source control.

Another common gotcha here: even if you add `.env` to `.gitignore`, if you committed the file before adding it to `.gitignore`, it’s still in your git history. I made this mistake on my second MCP project: I tested with a test API key, committed `.env` by accident, then added it to `.gitignore` on the next commit. I didn’t think anything of it until a few months later when we open-sourced the MCP framework, and a contributor found the test key in the git history of the public repo. Luckily it was just a test key with no access to production data, but it was a huge embarrassment and could have been way worse.

To avoid this, always scan your git repo for secrets before making it public, and use a tool like gitleaks or git-secrets to block commits that include secrets accidentally. Even for private repos, scanning is a good habit that stops secrets from leaking.

Key Tradeoffs for Environment Variables

| Pros | Cons |

|------|------|

| Works across all deployment platforms | Secrets can leak in crash logs if you don’t redact them |

| No extra infrastructure for development | Rotating secrets requires a server restart |

| Simple to implement | All users with deployment access can see secrets |

To mitigate the downsides, always redact secrets from logs. Here’s a simple middleware example for Express that redacts authorization headers:

```javascript

// Logging middleware that redacts secrets

app.use((req, res, next) => {

const safeHeaders = { ...req.headers };

if (safeHeaders.authorization) {

safeHeaders.authorization = '[REDACTED]';

}

console.log({ method: req.method, path: req.path, headers: safeHeaders });

next();

});

```

For production, use a dedicated secret manager like AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager instead of plain environment variables. These tools let you rotate secrets without restarting your server, and enforce fine-grained access control for who can read secrets. For production, the tradeoff between environment variables and secret managers is clear: if you’re a team of one building a side project, environment variables injected by your hosting provider are probably fine. But if you’re building any tool that handles sensitive data, use a secret manager. The overhead of setting up a secret manager is minimal, and the benefits (automatic rotation, fine-grained access, audit logs) are well worth it.

Token Validation Patterns

Proper token validation is make-or-break for security. Even if you issue tokens correctly, a bad validation flow lets attackers use expired or forged tokens to access your data. Here are the two most common patterns, with their practical tradeoffs:

  1. **Local Validation**: For JWT tokens, you verify the token’s signature locally using your auth provider’s public signing key (usually served via a JWKS endpoint). This is fast—no external HTTP call is needed per request, so you keep latency low for your MCP clients. The downside is that you can’t revoke a token before it expires unless you maintain a separate blocklist of revoked tokens.
  2. **Remote Validation**: You send the token to your auth provider’s introspection endpoint to check if it’s still valid. This lets you revoke tokens immediately, and all the state is managed by your auth provider. The downside is that it adds latency to every request (usually 5-20ms, depending on your provider) and your MCP availability becomes dependent on your auth provider’s availability.

For most MCP use cases, local validation with a small blocklist for revoked tokens is a good middle ground. You get the speed of local validation, and you can still revoke access immediately if a user’s account is compromised or they revoke access to your MCP. Below is an example of local JWT validation using JWKS, which works with almost all managed OAuth providers:

```javascript

const jwt = require('jsonwebtoken');

const jwksClient = require('jwks-rsa');

// Load signing keys from your auth provider's JWKS endpoint

const client = jwksClient({ jwksUri: process.env.JWKS_URI });

function getSigningKey(header, callback) {

client.getSigningKey(header.kid, (err, key) => {

callback(err, key.getPublicKey());

});

}

// In-memory blocklist for revoked token IDs (sync this from your database/Redis)

const revokedTokens = new Set();

// JWT validation middleware

function validateJwt(req, res, next) {

const token = req.headers.authorization?.split(' ')[1];

if (!token) return res.status(401).json({ error: 'Unauthorized' });

jwt.verify(token, getSigningKey, (err, decoded) => {

if (err) return res.status(401).json({ error: 'Invalid token' });

// Check if token is in your revoked token blocklist

if (revokedTokens.has(decoded.jti)) {

return res.status(401).json({ error: 'Token revoked' });

}

res.locals.user = decoded;

next();

});

}

```

The blocklist for revoked tokens can be stored in Redis for distributed setups, which adds almost no latency to the validation check. If you only have a handful of revocations a month, you can even cache the blocklist in memory and refresh it every few minutes to keep things fast. For high-traffic MCPs, this is the perfect balance between security and performance.

Adding Rate Limiting

Even with authentication, you need rate limiting to prevent abuse, stop brute force attacks on API keys, and avoid unexpected cost overruns from excessive requests. Rate limiting tracks how many requests a user or API key makes in a given window, and blocks requests that exceed your limit.

Rate limiting is especially important for MCP servers because each MCP request can pull large amounts of sensitive data from your systems. If an attacker gets hold of a single valid API key, they can scrape your entire context database in a few hundred requests. Rate limiting slows that attack down enough that you can detect the abnormal traffic and revoke the compromised key before too much data is leaked.

I made another silly mistake here when I first added rate limiting to our internal MCP: I configured it to rate limit by IP address, not by API key. That worked fine for our remote team that was all on different home IPs, until we had an all-hands on-site at a co-working space. 18 of us were all on the same public IP, and we hit the 100 requests per 15 minute limit halfway through our workshop when we were all testing the MCP to debug a product issue. We spent an hour trying to figure out why the MCP kept rejecting requests before I remembered I’d set it up to rate limit by IP. That’s why the example below uses the API key (or access token) as the rate limit key, not the IP address—multiple users sharing an IP is extremely common, especially for on-site teams or companies that use shared egress proxies.

Key Tradeoffs for Rate Limiting

| Pros | Cons |

|------|------|

| Stops brute force and accidental abuse | Too-strict limits block legitimate users |

| Low overhead to implement | Distributed setups require shared storage |

Adjust your limits based on your use case: internal MCPs can handle higher limits, while public MCPs should use tighter limits to prevent abuse. It’s also a good idea to add webhook alerts when a client hits the rate limit, so you can detect potential brute force or scraping attacks early.

Below is a working example of rate limiting

What To Do Next

Move from this guide to a concrete workflow and a matching tool page to apply the concepts.

References

Last updated: April 5, 2026

Sponsored