MCP Server Authentication and Authorization
Complete guide to securing MCP servers with authentication and authorization, covering API key validation, OAuth integration, JWT tokens, role-based access control, tool-level permissions, audit logging, and building secure multi-tenant MCP servers.
MCP Server Authentication and Authorization
Overview
An MCP server without authentication is an open door. Anyone who can connect can invoke your tools — query your database, read your files, call your APIs. For local stdio servers this might be acceptable, but the moment you deploy an MCP server over HTTP (SSE or WebSocket), authentication and authorization become mandatory. I have seen MCP servers deployed to staging environments with zero auth, exposing internal databases to anyone who found the URL. The patterns here prevent that scenario.
Prerequisites
- Node.js 16 or later
@modelcontextprotocol/sdkpackage installed- Express.js for HTTP-based MCP servers
jsonwebtokenpackage for JWT support- Understanding of MCP transport options (stdio, SSE, WebSocket)
- Basic understanding of OAuth 2.0 flows
- Familiarity with API security concepts
Authentication Strategies for MCP
Strategy Comparison
| Strategy | Complexity | Use Case | Transport |
|----------------|------------|-------------------------------|------------|
| API Key | Low | Server-to-server, internal | SSE, WS |
| Bearer Token | Medium | Service accounts, CI/CD | SSE, WS |
| OAuth 2.0 | High | User-facing, delegated access | SSE, WS |
| mTLS | High | Zero-trust, service mesh | SSE, WS |
| None (process) | None | Local CLI tools | stdio |
API Key Authentication
The simplest approach for server-to-server communication.
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var SSETransport = require("@modelcontextprotocol/sdk/server/sse.js").SSEServerTransport;
var express = require("express");
var crypto = require("crypto");
var app = express();
app.use(express.json());
// API key store (in production, use a database or secret manager)
var API_KEYS = {
"mcp_key_a1b2c3d4e5f6": {
name: "development-client",
roles: ["read"],
createdAt: "2026-01-01",
expiresAt: "2026-12-31"
},
"mcp_key_f6e5d4c3b2a1": {
name: "admin-client",
roles: ["read", "write", "admin"],
createdAt: "2026-01-01",
expiresAt: "2026-12-31"
}
};
// Authentication middleware
function authenticateApiKey(req, res, next) {
var authHeader = req.headers["authorization"];
var apiKey = null;
if (authHeader && authHeader.startsWith("Bearer ")) {
apiKey = authHeader.slice(7);
} else if (req.query.api_key) {
apiKey = req.query.api_key;
}
if (!apiKey) {
res.status(401).json({ error: "Missing API key. Provide via Authorization: Bearer <key> header." });
return;
}
var keyInfo = API_KEYS[apiKey];
if (!keyInfo) {
console.error("AUTH FAIL: Invalid API key: " + apiKey.substring(0, 8) + "...");
res.status(401).json({ error: "Invalid API key" });
return;
}
// Check expiration
if (new Date(keyInfo.expiresAt) < new Date()) {
res.status(401).json({ error: "API key expired" });
return;
}
// Attach identity to request
req.identity = {
name: keyInfo.name,
roles: keyInfo.roles,
authenticatedAt: new Date().toISOString()
};
console.error("AUTH OK: " + keyInfo.name + " [" + keyInfo.roles.join(", ") + "]");
next();
}
// Apply auth to MCP endpoints
app.use("/sse", authenticateApiKey);
app.use("/messages", authenticateApiKey);
// Session tracking with auth context
var sessions = {};
app.get("/sse", function(req, res) {
var transport = new SSETransport("/messages", res);
var sessionId = transport.sessionId;
sessions[sessionId] = {
transport: transport,
identity: req.identity,
connectedAt: new Date()
};
var server = createAuthorizedServer(req.identity);
server.connect(transport);
res.on("close", function() {
delete sessions[sessionId];
});
});
app.post("/messages", function(req, res) {
var sessionId = req.query.sessionId;
if (!sessions[sessionId]) {
res.status(404).json({ error: "Session not found" });
return;
}
sessions[sessionId].transport.handlePostMessage(req, res);
});
// Health endpoint (no auth required)
app.get("/health", function(req, res) {
res.json({ status: "ok", activeSessions: Object.keys(sessions).length });
});
var PORT = process.env.PORT || 3001;
app.listen(PORT, function() {
console.log("Authenticated MCP server on port " + PORT);
});
Generating API Keys
var crypto = require("crypto");
function generateApiKey(prefix) {
prefix = prefix || "mcp_key";
var random = crypto.randomBytes(24).toString("base64url");
return prefix + "_" + random;
}
// Generate keys with hashing for storage
function createApiKeyRecord(name, roles) {
var plainKey = generateApiKey();
var hashedKey = crypto.createHash("sha256").update(plainKey).digest("hex");
var record = {
hashedKey: hashedKey,
name: name,
roles: roles,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() // 90 days
};
console.log("API Key generated for " + name + ":");
console.log(" Key: " + plainKey);
console.log(" Expires: " + record.expiresAt);
console.log(" IMPORTANT: Store this key securely. It cannot be retrieved later.");
return { plainKey: plainKey, record: record };
}
// Validate using hash comparison (constant-time)
function validateApiKey(providedKey, storedHash) {
var providedHash = crypto.createHash("sha256").update(providedKey).digest("hex");
return crypto.timingSafeEqual(Buffer.from(providedHash), Buffer.from(storedHash));
}
JWT Authentication
For stateless authentication with expiring tokens.
var jwt = require("jsonwebtoken");
var JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(32).toString("hex");
var JWT_ISSUER = "mcp-auth-server";
// Issue a JWT token
function issueToken(clientId, roles, expiresIn) {
var payload = {
sub: clientId,
roles: roles,
iss: JWT_ISSUER,
iat: Math.floor(Date.now() / 1000)
};
return jwt.sign(payload, JWT_SECRET, {
expiresIn: expiresIn || "1h",
algorithm: "HS256"
});
}
// JWT authentication middleware
function authenticateJwt(req, res, next) {
var authHeader = req.headers["authorization"];
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing Bearer token" });
return;
}
var token = authHeader.slice(7);
try {
var decoded = jwt.verify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
algorithms: ["HS256"]
});
req.identity = {
clientId: decoded.sub,
roles: decoded.roles || [],
tokenIssuedAt: new Date(decoded.iat * 1000).toISOString(),
tokenExpiresAt: new Date(decoded.exp * 1000).toISOString()
};
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
res.status(401).json({ error: "Token expired", expiredAt: err.expiredAt });
} else if (err.name === "JsonWebTokenError") {
res.status(401).json({ error: "Invalid token: " + err.message });
} else {
res.status(401).json({ error: "Authentication failed" });
}
}
}
// Token endpoint
app.post("/auth/token", express.json(), function(req, res) {
var clientId = req.body.client_id;
var clientSecret = req.body.client_secret;
// Validate client credentials (check against database in production)
var client = validateClientCredentials(clientId, clientSecret);
if (!client) {
res.status(401).json({ error: "Invalid client credentials" });
return;
}
var token = issueToken(clientId, client.roles, "1h");
res.json({
access_token: token,
token_type: "Bearer",
expires_in: 3600,
scope: client.roles.join(" ")
});
});
function validateClientCredentials(clientId, clientSecret) {
// In production, look up in database with hashed secret comparison
var clients = {
"mcp-client-1": { secret: process.env.CLIENT_1_SECRET, roles: ["read"] },
"mcp-admin": { secret: process.env.ADMIN_SECRET, roles: ["read", "write", "admin"] }
};
var client = clients[clientId];
if (!client || client.secret !== clientSecret) return null;
return client;
}
Role-Based Access Control (RBAC)
Control which tools each identity can access.
// RBAC permission system
var PERMISSIONS = {
read: [
"db.query", "db.tables", "db.describe",
"file.read", "file.search",
"git.status", "git.log",
"project.structure", "project.dependencies"
],
write: [
"file.write", "file.delete",
"git.commit", "git.push"
],
admin: [
"server.stats", "server.config",
"auth.list-keys", "auth.revoke-key"
]
};
function hasPermission(identity, toolName) {
if (!identity || !identity.roles) return false;
return identity.roles.some(function(role) {
var allowed = PERMISSIONS[role] || [];
return allowed.indexOf(toolName) !== -1;
});
}
// Create a server instance with authorization checks
function createAuthorizedServer(identity) {
var server = new Server(
{ name: "authorized-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Only list tools the identity can access
server.setRequestHandler("tools/list", function() {
var allowedTools = [];
Object.keys(allTools).forEach(function(name) {
if (hasPermission(identity, name)) {
allowedTools.push({
name: name,
description: allTools[name].description,
inputSchema: allTools[name].inputSchema
});
}
});
return { tools: allowedTools };
});
// Enforce authorization on tool calls
server.setRequestHandler("tools/call", function(request) {
var toolName = request.params.name;
if (!hasPermission(identity, toolName)) {
console.error("AUTHZ DENY: " + identity.name + " -> " + toolName);
return {
content: [{
type: "text",
text: "Access denied. Your role does not have permission to use '" + toolName + "'. "
+ "Required: one of [" + getRequiredRoles(toolName).join(", ") + "]. "
+ "Your roles: [" + identity.roles.join(", ") + "]."
}],
isError: true
};
}
console.error("AUTHZ OK: " + identity.name + " -> " + toolName);
return allTools[toolName].handler(request.params.arguments || {});
});
return server;
}
function getRequiredRoles(toolName) {
var roles = [];
Object.keys(PERMISSIONS).forEach(function(role) {
if (PERMISSIONS[role].indexOf(toolName) !== -1) {
roles.push(role);
}
});
return roles;
}
Audit Logging
Every authenticated action should be logged for security review.
var fs = require("fs");
function AuditLogger(logPath) {
this.logPath = logPath || "mcp-audit.log";
this.stream = fs.createWriteStream(this.logPath, { flags: "a" });
}
AuditLogger.prototype.log = function(event) {
var entry = {
timestamp: new Date().toISOString(),
event: event.type,
identity: event.identity,
tool: event.tool || null,
args: event.args ? JSON.stringify(event.args).substring(0, 500) : null,
result: event.result || null,
ip: event.ip || null,
duration: event.duration || null
};
this.stream.write(JSON.stringify(entry) + "\n");
// Also log to stderr for immediate visibility
console.error("[AUDIT] " + entry.event + " | " + entry.identity + " | " + (entry.tool || "-"));
};
AuditLogger.prototype.close = function() {
this.stream.end();
};
var audit = new AuditLogger();
// Wrap tool execution with audit logging
function auditedToolCall(identity, toolName, args, handler) {
var start = Date.now();
audit.log({
type: "tool_call_start",
identity: identity.name,
tool: toolName,
args: args
});
return Promise.resolve(handler(args))
.then(function(result) {
audit.log({
type: "tool_call_success",
identity: identity.name,
tool: toolName,
duration: Date.now() - start,
result: result.isError ? "error" : "success"
});
return result;
})
.catch(function(err) {
audit.log({
type: "tool_call_error",
identity: identity.name,
tool: toolName,
duration: Date.now() - start,
result: err.message
});
throw err;
});
}
// Log authentication events
function auditAuth(type, identity, ip, details) {
audit.log({
type: "auth_" + type,
identity: identity || "unknown",
ip: ip,
result: details
});
}
Multi-Tenant MCP Server
Serve multiple clients with isolated data access.
// Tenant configuration
var TENANTS = {
"tenant-alpha": {
name: "Alpha Corp",
databaseUrl: process.env.ALPHA_DB_URL,
fileRoot: "/data/alpha",
allowedTools: ["db.query", "db.tables", "file.read", "file.search"],
queryRowLimit: 100
},
"tenant-beta": {
name: "Beta Inc",
databaseUrl: process.env.BETA_DB_URL,
fileRoot: "/data/beta",
allowedTools: ["db.query", "db.tables", "db.describe", "file.read", "file.write"],
queryRowLimit: 500
}
};
function getTenantFromToken(token) {
var decoded = jwt.verify(token, JWT_SECRET);
var tenantId = decoded.tenant;
if (!TENANTS[tenantId]) {
throw new Error("Unknown tenant: " + tenantId);
}
return {
id: tenantId,
config: TENANTS[tenantId],
identity: {
clientId: decoded.sub,
roles: decoded.roles,
tenant: tenantId
}
};
}
// Create tenant-isolated server
function createTenantServer(tenant) {
var pg = require("pg");
var pool = new pg.Pool({
connectionString: tenant.config.databaseUrl,
max: 3
});
var server = new Server(
{ name: "mcp-" + tenant.id, version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Tool: Query (tenant-scoped)
var tenantTools = {
"db.query": {
description: "Query the database (read-only, max " + tenant.config.queryRowLimit + " rows)",
inputSchema: {
type: "object",
properties: { sql: { type: "string" }, params: { type: "array", items: { type: "string" } } },
required: ["sql"]
},
handler: function(args) {
var sql = args.sql.trim();
if (!sql.toUpperCase().startsWith("SELECT")) {
return { content: [{ type: "text", text: "Only SELECT queries allowed" }], isError: true };
}
sql = sql.replace(/;?\s*$/, "") + " LIMIT " + tenant.config.queryRowLimit;
return pool.query(sql, args.params || []).then(function(result) {
return { content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }] };
});
}
},
"file.read": {
description: "Read a file (within tenant directory only)",
inputSchema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"]
},
handler: function(args) {
var fullPath = path.resolve(tenant.config.fileRoot, args.path);
// Tenant isolation: ensure path is within tenant's directory
if (!fullPath.startsWith(path.resolve(tenant.config.fileRoot))) {
return { content: [{ type: "text", text: "Access denied: path outside tenant directory" }], isError: true };
}
var content = fs.readFileSync(fullPath, "utf8");
return { content: [{ type: "text", text: content }] };
}
}
};
// Only register tools the tenant is allowed to use
server.setRequestHandler("tools/list", function() {
var allowed = tenant.config.allowedTools;
var tools = [];
allowed.forEach(function(name) {
if (tenantTools[name]) {
tools.push({
name: name,
description: tenantTools[name].description,
inputSchema: tenantTools[name].inputSchema
});
}
});
return { tools: tools };
});
server.setRequestHandler("tools/call", function(request) {
var toolName = request.params.name;
if (tenant.config.allowedTools.indexOf(toolName) === -1) {
return {
content: [{ type: "text", text: "Tool not available for your tenant" }],
isError: true
};
}
if (!tenantTools[toolName]) {
return { content: [{ type: "text", text: "Unknown tool" }], isError: true };
}
return tenantTools[toolName].handler(request.params.arguments || {});
});
return server;
}
Complete Working Example: Secure MCP Server
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var SSETransport = require("@modelcontextprotocol/sdk/server/sse.js").SSEServerTransport;
var express = require("express");
var crypto = require("crypto");
var fs = require("fs");
// ============================================================
// Secure MCP Server with Authentication and Authorization
// ============================================================
var app = express();
app.use(express.json());
var config = {
port: process.env.PORT || 3001,
apiKeys: JSON.parse(process.env.API_KEYS || '{}'),
auditLog: process.env.AUDIT_LOG || "mcp-audit.jsonl"
};
// ---- Audit Log ----
var auditStream = fs.createWriteStream(config.auditLog, { flags: "a" });
function auditLog(event) {
var entry = Object.assign({
timestamp: new Date().toISOString()
}, event);
auditStream.write(JSON.stringify(entry) + "\n");
}
// ---- Authentication ----
function authenticate(req, res, next) {
var authHeader = req.headers["authorization"];
if (!authHeader || !authHeader.startsWith("Bearer ")) {
auditLog({ event: "auth_failure", reason: "missing_token", ip: req.ip });
res.status(401).json({ error: "Authentication required" });
return;
}
var key = authHeader.slice(7);
var keyHash = crypto.createHash("sha256").update(key).digest("hex");
var keyInfo = config.apiKeys[keyHash];
if (!keyInfo) {
auditLog({ event: "auth_failure", reason: "invalid_key", ip: req.ip });
res.status(401).json({ error: "Invalid credentials" });
return;
}
if (keyInfo.expiresAt && new Date(keyInfo.expiresAt) < new Date()) {
auditLog({ event: "auth_failure", reason: "expired_key", identity: keyInfo.name, ip: req.ip });
res.status(401).json({ error: "Credentials expired" });
return;
}
req.identity = { name: keyInfo.name, roles: keyInfo.roles || ["read"] };
auditLog({ event: "auth_success", identity: req.identity.name, ip: req.ip });
next();
}
// ---- Rate Limiting ----
var rateLimits = {};
function rateLimit(req, res, next) {
var key = req.identity ? req.identity.name : req.ip;
var now = Date.now();
var windowMs = 60000;
var maxRequests = 120;
if (!rateLimits[key]) rateLimits[key] = [];
rateLimits[key] = rateLimits[key].filter(function(t) { return now - t < windowMs; });
if (rateLimits[key].length >= maxRequests) {
auditLog({ event: "rate_limited", identity: key });
res.status(429).json({ error: "Rate limit exceeded" });
return;
}
rateLimits[key].push(now);
next();
}
// ---- Tool Definitions ----
var TOOLS = {
"echo": {
description: "Echo a message (for testing)",
roles: ["read"],
inputSchema: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"]
},
handler: function(args) {
return { content: [{ type: "text", text: args.message }] };
}
},
"server-info": {
description: "Get server information",
roles: ["admin"],
inputSchema: { type: "object", properties: {} },
handler: function() {
return {
content: [{
type: "text",
text: JSON.stringify({
uptime: process.uptime(),
sessions: Object.keys(sessions).length,
memory: Math.round(process.memoryUsage().heapUsed / 1048576) + "MB"
}, null, 2)
}]
};
}
}
};
function canAccess(identity, toolName) {
var tool = TOOLS[toolName];
if (!tool) return false;
return tool.roles.some(function(requiredRole) {
return identity.roles.indexOf(requiredRole) !== -1;
});
}
// ---- Session Management ----
var sessions = {};
app.get("/sse", authenticate, rateLimit, function(req, res) {
var transport = new SSETransport("/messages", res);
var sessionId = transport.sessionId;
var identity = req.identity;
sessions[sessionId] = { transport: transport, identity: identity };
auditLog({ event: "session_start", identity: identity.name, sessionId: sessionId });
var server = new Server(
{ name: "secure-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", function() {
var allowed = Object.keys(TOOLS).filter(function(name) {
return canAccess(identity, name);
}).map(function(name) {
return { name: name, description: TOOLS[name].description, inputSchema: TOOLS[name].inputSchema };
});
return { tools: allowed };
});
server.setRequestHandler("tools/call", function(request) {
var toolName = request.params.name;
var args = request.params.arguments || {};
if (!canAccess(identity, toolName)) {
auditLog({ event: "authz_denied", identity: identity.name, tool: toolName });
return {
content: [{ type: "text", text: "Access denied for tool: " + toolName }],
isError: true
};
}
auditLog({ event: "tool_call", identity: identity.name, tool: toolName });
return TOOLS[toolName].handler(args);
});
server.connect(transport);
res.on("close", function() {
auditLog({ event: "session_end", identity: identity.name, sessionId: sessionId });
delete sessions[sessionId];
});
});
app.post("/messages", authenticate, rateLimit, function(req, res) {
var sessionId = req.query.sessionId;
if (!sessions[sessionId]) {
res.status(404).json({ error: "Session not found" });
return;
}
sessions[sessionId].transport.handlePostMessage(req, res);
});
app.get("/health", function(req, res) {
res.json({ status: "ok" });
});
app.listen(config.port, function() {
console.log("Secure MCP server on port " + config.port);
});
Common Issues & Troubleshooting
"401 Unauthorized" on Every Request
The API key might not be sent correctly. Check the Authorization header format:
# Correct
curl -H "Authorization: Bearer mcp_key_abc123" https://mcp-server/sse
# Wrong (missing Bearer prefix)
curl -H "Authorization: mcp_key_abc123" https://mcp-server/sse
# Wrong (API key in wrong header)
curl -H "X-API-Key: mcp_key_abc123" https://mcp-server/sse
JWT Token Expires During Long MCP Session
SSE connections can last hours, but JWT tokens expire:
// Implement token refresh in the client
function refreshToken(client) {
return fetch("/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: "xxx", client_secret: "yyy", grant_type: "refresh_token" })
}).then(function(res) { return res.json(); });
}
// On the server, check token validity periodically
// and reject requests after token expiry with a clear error
Tool Authorization Check Bypassed
If tool authorization only happens in tools/call but not tools/list, the model sees tools it cannot use and wastes turns trying:
// Always filter tools/list AND tools/call
// tools/list: only show tools the identity can access
// tools/call: enforce the same check (defense in depth)
Audit Log File Grows Without Bound
// Rotate audit logs daily
var logRotate = require("log-rotate");
setInterval(function() {
logRotate(config.auditLog, { count: 30 }, function(err) {
if (err) console.error("Log rotation failed:", err);
});
}, 86400000); // Every 24 hours
// Or use a size-based rotation
var maxLogSize = 50 * 1024 * 1024; // 50MB
Best Practices
- Always authenticate HTTP-based MCP servers — stdio servers inherit process-level security. SSE and WebSocket servers are network-accessible and must authenticate every connection.
- Use constant-time comparison for API keys —
crypto.timingSafeEqualprevents timing attacks that can leak key values byte by byte. - Filter tools/list based on identity — Only show tools the authenticated identity can use. This prevents models from attempting calls that will be denied.
- Log every authentication attempt and tool call — The audit log is your forensic trail. Include identity, tool name, timestamp, IP address, and success/failure status.
- Rotate API keys on a schedule — No key should live forever. Implement 90-day rotation with a grace period where both old and new keys are valid.
- Use JWT for stateless authentication — JWTs let you verify identity without a database lookup on every request. Include roles in the token payload for authorization decisions.
- Implement rate limiting per identity — A compromised key should not be able to DDoS your database through the MCP server. Limit to a reasonable number of calls per minute.
- Isolate tenant data completely — In multi-tenant servers, ensure one tenant's database connection, file access, and configuration cannot leak to another tenant through any code path.