Secure an MCP Server with Bearer Auth, Tool Scoping, and Validation
For developers running a Model Context Protocol server behind HTTP. This walks you through adding bearer-token authentication, restricting which tools are exposed per client, and rejecting unsafe inputs with concrete middleware, reverse-proxy rules, and verification commands.
TL;DR — Lock down your MCP server in three layers: require a bearer token at the HTTP edge, expose only the minimum tool set per client identity, and reject malformed or dangerous tool arguments before they reach your business logic. The most common fix is to stop trusting the client to self-restrict tools: enforce tool allowlists server-side from the authenticated identity. Reading time: ~5 min
Goal
When you finish, your MCP server only answers authenticated requests, each client can call only the tools you explicitly allow for that identity, and invalid or dangerous tool inputs are rejected with clear 4xx errors before any side effects happen.
Prerequisites
- Shell access to the host running your MCP server
- A reverse proxy already installed or installable: nginx
>= 1.24— check withnginx -v - Node.js
>= 20if your MCP server is Node-based — check withnode --version curl >= 8— check withcurl --versionjqfor JSON inspection — check withjq --version- A way to restart your MCP process (
systemctl, Docker, or your process manager) - One strong random token per client/app identity
- The exact list of tools each client should be allowed to call
- The exact JSON schema or argument rules for each tool
Steps
Step 1: Generate a bearer token per client
Run one command per client identity and store the output in your secret manager or deployment environment.
openssl rand -hex 32
Example output:
9f4b0f4a4f2f0c5d4d4c3b2a1908e7f6d5c4b3a291817161514131211100ffab
You should see a 64-character hex string.
Step 2: Put the token-to-tool mapping in environment variables
On the MCP server host, create an env file with explicit identities and tool allowlists.
sudo install -d -m 0750 /etc/mcp
sudo tee /etc/mcp/server.env >/dev/null <<'EOF'
MCP_TOKENS={"agent-a":"9f4b0f4a4f2f0c5d4d4c3b2a1908e7f6d5c4b3a291817161514131211100ffab","agent-b":"2b8e8b1f0b7f1c9a6f5e4d3c2b1a09182736455463728190abcdefabcdef1234"}
MCP_TOOL_SCOPE={"agent-a":["search_docs","get_ticket"],"agent-b":["search_docs"]}
EOF
sudo chmod 0640 /etc/mcp/server.env
You should see no output and /etc/mcp/server.env should exist.
Step 3: Enforce bearer auth and tool scoping in the MCP server
If your server is Node/Express-based, add middleware exactly like this before your MCP request handler. Adjust only the tool names.
import express from "express";
const app = express();
app.use(express.json({ limit: "256kb" }));
const TOKENS = JSON.parse(process.env.MCP_TOKENS || "{}");
const TOOL_SCOPE = JSON.parse(process.env.MCP_TOOL_SCOPE || "{}");
function auth(req, res, next) {
const h = req.get("authorization") || "";
const m = h.match(/^Bearer\s+(.+)$/i);
if (!m) {
return res.status(401).json({ error: "missing_bearer_token" });
}
const token = m[1];
const clientId = Object.keys(TOKENS).find((k) => TOKENS[k] === token);
if (!clientId) {
return res.status(403).json({ error: "invalid_token" });
}
req.clientId = clientId;
req.allowedTools = new Set(TOOL_SCOPE[clientId] || []);
next();
}
function validateToolCall(req, res, next) {
const tool = req.body?.params?.name;
const args = req.body?.params?.arguments;
if (!tool) {
return res.status(400).json({ error: "missing_tool_name" });
}
if (!req.allowedTools.has(tool)) {
return res.status(403).json({ error: "tool_not_allowed", tool, clientId: req.clientId });
}
if (tool === "search_docs") {
if (typeof args?.query !== "string" || args.query.length < 1 || args.query.length > 200) {
return res.status(400).json({ error: "invalid_arguments", field: "query" });
}
}
if (tool === "get_ticket") {
if (!/^TCK-[0-9]{1,10}$/.test(String(args?.ticketId || ""))) {
return res.status(400).json({ error: "invalid_arguments", field: "ticketId" });
}
}
next();
}
app.post("/mcp", auth, validateToolCall, async (req, res) => {
res.status(200).json({ ok: true });
});
app.listen(3000, "127.0.0.1");
You should see your server start and bind to 127.0.0.1:3000.
Step 4: Block unauthenticated requests at nginx before they hit the app
⚠️ Reloading nginx with a broken config can cause downtime. Run
nginx -tbeforesystemctl reload nginx.
Create a reverse-proxy config that rejects requests without Authorization: Bearer ... and forwards only to the local app.
server {
listen 443 ssl http2;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location /mcp {
if ($http_authorization = "") { return 401; }
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_read_timeout 300s;
}
}
Enable and reload it.
sudo nginx -t && sudo systemctl reload nginx
You should see syntax is ok and test is successful.
Step 5: Start the server with the env file
If you use systemd, wire the env file into the service.
[Unit]
Description=MCP Server
After=network.target
[Service]
EnvironmentFile=/etc/mcp/server.env
ExecStart=/usr/bin/node /opt/mcp/server.js
Restart=always
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
Reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart mcp-server
sudo systemctl status --no-pager mcp-server
You should see active (running).
Step 6: Add request logging that includes client identity but not the token
Use app logs or proxy logs, but log the resolved client ID, tool name, and rejection reason. For a Node app, add this line inside auth after req.clientId = clientId; and another inside each rejection branch.
console.info(JSON.stringify({ event: "mcp_auth_ok", clientId }));
For a tool rejection:
console.warn(JSON.stringify({ event: "mcp_tool_denied", clientId: req.clientId, tool }));
You should see log lines with clientId and no raw bearer token.
Verify it works
Run these exact checks from any machine that can reach the server.
curl -i https://mcp.example.com/mcp
Expected shape:
HTTP/2 401
server: nginx
content-type: text/html
Now send an invalid token.
curl -i https://mcp.example.com/mcp \
-H 'Authorization: Bearer wrong-token' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"reset password"}}}'
Expected shape:
HTTP/2 403
content-type: application/json; charset=utf-8
{"error":"invalid_token"}
Now send a valid token for a forbidden tool.
curl -i https://mcp.example.com/mcp \
-H 'Authorization: Bearer 2b8e8b1f0b7f1c9a6f5e4d3c2b1a09182736455463728190abcdefabcdef1234' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_ticket","arguments":{"ticketId":"TCK-123"}}}'
Expected shape:
HTTP/2 403
content-type: application/json; charset=utf-8
{"error":"tool_not_allowed","tool":"get_ticket","clientId":"agent-b"}
Now send a valid token and valid tool call.
curl -i https://mcp.example.com/mcp \
-H 'Authorization: Bearer 9f4b0f4a4f2f0c5d4d4c3b2a1908e7f6d5c4b3a291817161514131211100ffab' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_ticket","arguments":{"ticketId":"TCK-123"}}}'
Expected shape:
HTTP/2 200
content-type: application/json; charset=utf-8
{"ok":true}
Common pitfalls
Proxy strips the Authorization header
Mistake: nginx or another proxy forwards the request without Authorization.
Symptom: every request returns 401 or {"error":"missing_bearer_token"} even though curl includes the header.
Fix: add proxy_set_header Authorization $http_authorization; and reload nginx.
Tool scoping is enforced only in the client
Mistake: the client hides tools in its UI, but the server still accepts direct tools/call requests for all tools.
Symptom: a crafted curl call succeeds for a tool the client was not supposed to use.
Fix: reject unauthorized tool names server-side with 403 tool_not_allowed before dispatch.
Input validation checks type but not format or size
Mistake: validating only that ticketId is a string or query exists.
Symptom: downstream errors, oversized payloads, or injection into shell/SQL/file paths.
Fix: add explicit regex, length, enum, and payload-size limits; keep express.json({ limit: "256kb" }) or equivalent.
App listens on 0.0.0.0 instead of 127.0.0.1
Mistake: the MCP app is directly reachable on its port, bypassing nginx auth checks.
Symptom: curl http://server-ip:3000/mcp works without the proxy behavior.
Fix: bind the app to 127.0.0.1 and block the port in the host firewall.
Logging raw tokens
Mistake: dumping all request headers in access logs or app logs.
Symptom: bearer tokens appear in log aggregation, shell history, or incident exports.
Fix: log resolved clientId, tool name, and status only; never log Authorization headers.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI