- Posted on
- • Artificial Intelligence
MCP Security Best Practices
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
MCP Security Best Practices: Hardening Your Model Context Protocol on Linux
If your LLM can touch production, you’ve just created a new security perimeter. The Model Context Protocol (MCP) connects LLM clients to real tools and data via MCP servers. That power is a game changer—but it also creates fresh paths for misuse: prompt injections that try to run dangerous tools, secrets exfiltration, and lateral movement across your network.
This guide is a practical, Bash-first hardening checklist for running MCP servers safely on Linux. You’ll learn why these controls matter and how to apply them with concrete commands, systemd units, and firewall rules you can drop into your hosts today.
Why MCP security deserves your attention
MCP servers expose “capabilities” that may read files, query databases, call HTTP APIs, or even run local commands. A single insecure tool can become an attacker’s remote control.
Prompt injection can subvert an otherwise benign task into data exfiltration or command abuse.
Secrets embedded in configs, weak transport security, or broad filesystem access will turn a small mistake into a major breach.
Good news: standard Linux hardening (least privilege, isolation, auditing, and strong transport) maps neatly onto MCP.
Quick installs: tools we’ll use
Pick the packages you need from these categories. You don’t have to install everything; choose based on your architecture. Where there are alternatives (for example UFW vs firewalld), choose one.
Container/sandbox: podman, bubblewrap
Firewall: ufw or firewalld
MAC/LSM: AppArmor or SELinux (distro-specific)
Auditing and auth: auditd, fail2ban
TLS/reverse proxy: openssl, nginx
Secrets: pass (aka password-store on SUSE)
Utilities: jq
Debian/Ubuntu (apt):
sudo apt update
# Choose one firewall:
sudo apt install -y ufw
# or:
sudo apt install -y firewalld
# Core tools:
sudo apt install -y podman bubblewrap auditd fail2ban openssl nginx wireguard-tools jq pass
# AppArmor (common on Debian/Ubuntu):
sudo apt install -y apparmor apparmor-utils
# Optional SELinux tooling (if you run SELinux on Debian/Ubuntu):
sudo apt install -y policycoreutils selinux-utils
Fedora/RHEL/CentOS (dnf):
sudo dnf -y install firewalld podman bubblewrap audit fail2ban openssl nginx wireguard-tools jq pass
# SELinux is default on Fedora/RHEL; useful extras:
sudo dnf -y install policycoreutils policycoreutils-python-utils setools-console
openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y firewalld podman bubblewrap audit fail2ban openssl nginx wireguard-tools jq password-store
# AppArmor is default on openSUSE:
sudo zypper install -y apparmor apparmor-utils
Note: Do not run UFW and firewalld simultaneously. Pick one.
1) Least privilege and sandboxing: contain the blast radius
Principle: your MCP server should access only the files, devices, and network it truly needs. Use a dedicated system user, strong systemd restrictions, and (optionally) bubblewrap or a container runtime.
Create a dedicated user and directories:
sudo useradd --system --create-home --home-dir /var/lib/mcp --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp -m 0750 /var/lib/mcp /etc/mcp
Example systemd service with tight defaults (drop into /etc/systemd/system/mcp-foo.service):
[Unit]
Description=MCP server (foo)
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=mcp
Group=mcp
# Environment file for secrets/config (chmod 600)
EnvironmentFile=/etc/mcp/foo.env
# If your MCP server can bind a Unix socket:
Environment=SOCKET=/run/mcp/foo.sock
# Strong systemd sandboxing:
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
PrivateDevices=true
LockPersonality=true
CapabilityBoundingSet=
SystemCallFilter=@system-service
RestrictSUIDSGID=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# If your server does NOT need outbound network, uncomment:
# PrivateNetwork=true
# Only these paths are writable:
ReadWritePaths=/var/lib/mcp /run/mcp
# Create /run/mcp with secure perms:
RuntimeDirectory=mcp
RuntimeDirectoryMode=0750
# Exec: run your server. Prefer a Unix socket over TCP if possible.
ExecStart=/usr/local/bin/your-mcp-server --socket ${SOCKET}
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now mcp-foo.service
Optional: add bubblewrap for an extra FS jail:
# Replace ExecStart with a bubblewrap wrapper:
ExecStart=/usr/bin/bwrap \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--bind /var/lib/mcp /app \
--dev /dev \
--proc /proc \
--tmpfs /tmp \
/usr/local/bin/your-mcp-server --socket ${SOCKET} --data-dir /app
Alternatively, run rootless with Podman:
# As an unprivileged user (recommended: dedicated service user):
podman run --rm --name mcp-foo \
--net=none \ # or a minimal network as needed
--cap-drop=ALL \
--security-opt no-new-privileges \
--read-only \
-v /run/mcp:/run/mcp:Z \
-v /var/lib/mcp:/app:Z \
-e SOCKET=/run/mcp/foo.sock \
--user 1000:1000 \
docker.io/yourorg/your-mcp-server:TAG \
--socket ${SOCKET} --data-dir /app
Why it matters:
Dedicated users and mount namespaces stop “read the admin’s $HOME” style prompt injections.
NoNewPrivileges + empty CapabilityBoundingSet neuter privilege escalations.
PrivateTmp, ProtectSystem, and ReadWritePaths force you to explicitly allow only what’s necessary.
2) Prefer Unix sockets and strict network controls
Bind MCP servers to a Unix domain socket whenever the client is on the same machine. If you must expose TCP, restrict it tightly and use TLS.
Bind to a Unix socket (already shown via --socket). If you must use TCP, bind to localhost only:
/usr/local/bin/your-mcp-server --host 127.0.0.1 --port 9000
Firewall examples
- UFW (Debian/Ubuntu):
# Default deny
sudo ufw default deny incoming
sudo ufw default allow outgoing
# If you must expose 8443 to a trusted subnet only:
sudo ufw allow from 10.0.0.0/24 to any port 8443 proto tcp
sudo ufw enable
sudo ufw status verbose
- firewalld (Fedora/RHEL/openSUSE):
sudo systemctl enable --now firewalld
# Allow only a specific source to reach 8443:
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.0.0.0/24" port protocol="tcp" port="8443" accept'
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Remote access without opening a port: use SSH tunneling
# From your workstation:
ssh -N -L 8443:127.0.0.1:9000 user@server
# Now connect locally to https://127.0.0.1:8443 while the server only listens on 127.0.0.1:9000
Why it matters:
Unix sockets reduce exposure to the network stack.
Minimal listening interfaces and explicit firewall rules shrink your attack surface.
SSH tunnels or WireGuard keep your MCP off the public internet entirely.
3) Secrets management: no plaintext keys in repos
Keep API keys and tokens out of code and world-readable files.
Create a tight-permission env file:
sudo bash -c 'cat >/etc/mcp/foo.env' <<'EOF'
# chmod 600, owned by root
MCP_API_TOKEN=replace_me
DB_DSN=postgres://mcp_user:REDACTED@db.internal:5432/app
EOF
sudo chown root:root /etc/mcp/foo.env
sudo chmod 600 /etc/mcp/foo.env
sudo systemctl restart mcp-foo.service
Use pass (password-store) for operator workflows:
# Initialize your GPG key (assumes you have one)
pass init "Your GPG Key ID"
pass insert -m mcp/foo/MCP_API_TOKEN
pass show mcp/foo/MCP_API_TOKEN
Rotate and scope everything:
Prefer short‑lived, scoped credentials (e.g., cloud STS) over long‑lived static keys.
Give MCP servers their own IAM principal with least privilege.
Never let your MCP have blanket read of $HOME or /etc where credentials often live.
Why it matters:
Secrets in environment files are still better than in code repos; strict perms reduce blast radius.
Rotation and scoping are your safety net if a key leaks via a tool or log.
4) Authentication and transport: assume the network is hostile
If you must expose MCP over TCP, put it behind a TLS reverse proxy and require client auth. For local sockets, prefer SSH or mTLS between client and server.
Generate a self-signed cert (demo/dev):
mkdir -p /etc/nginx/mcp
sudo openssl req -x509 -nodes -newkey rsa:4096 -days 365 \
-keyout /etc/nginx/mcp/mcp.key \
-out /etc/nginx/mcp/mcp.crt \
-subj "/CN=mcp.example.internal"
sudo chmod 600 /etc/nginx/mcp/mcp.key
Nginx as a TLS reverse proxy to a Unix socket:
# /etc/nginx/conf.d/mcp-foo.conf
upstream mcp_foo {
server unix:/run/mcp/foo.sock;
}
server {
listen 8443 ssl;
server_name mcp.example.internal;
ssl_certificate /etc/nginx/mcp/mcp.crt;
ssl_certificate_key /etc/nginx/mcp/mcp.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Optionally add basic auth or mTLS here.
# auth_basic "Restricted";
# auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://mcp_foo;
}
}
Reload nginx:
sudo nginx -t && sudo systemctl reload nginx
Add fail2ban to block brute‑force auth on the proxy:
sudo systemctl enable --now fail2ban
# Example jail (adjust for your distro’s paths):
sudo bash -c 'cat >/etc/fail2ban/jail.d/nginx-auth.conf' <<'EOF'
[nginx-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 5
bantime = 3600
EOF
sudo systemctl restart fail2ban
Why it matters:
TLS prevents credential and prompt leakage in transit.
Reverse proxies centralize rate limiting and auth, making abuses observable and controllable.
Fail2ban cuts off repeated probes quickly.
5) Observability and policy: log, alert, and constrain behavior
Audit what matters:
# Ensure auditd is running
sudo systemctl enable --now auditd
# Watch the MCP binary execution
echo "-w /usr/local/bin/your-mcp-server -p x -k mcp_exec" | sudo tee /etc/audit/rules.d/mcp.rules
# Watch secrets directory reads (metadata)
echo "-w /etc/mcp -p r -k mcp_secrets" | sudo tee -a /etc/audit/rules.d/mcp.rules
sudo augenrules --load
Scrub PII/secrets in logs. If your MCP server supports JSON logs, index them and alert on:
Unexpected tool names
Excessive file reads
Outbound network spikes to unfamiliar hosts
Add a simple policy layer:
Allowlist tool names and arguments the MCP server is permitted to call.
If you must run local commands, wrap them with
sudorules that permit only exact binaries and flags:
# /etc/sudoers.d/mcp (use visudo -f /etc/sudoers.d/mcp)
mcp ALL=(root) NOPASSWD: /usr/local/bin/safe-tool --mode readonly *
Why it matters:
Audit gives you forensics and early-warning signals.
Allowlists constrain prompt injection impact: “unknown tool” calls just fail.
Minimal sudo rules are safer than letting the server run arbitrary shell.
Real-world example: from “whoops” to “contained”
A team exposed an MCP server that fetched data from S3 and also allowed “run local command.” A prompt injection convinced the LLM to cat credentials from $HOME. After hardening:
The service runs as user mcp with ProtectHome=read-only and a read-only root FS via bubblewrap.
S3 access uses a role with a tight bucket policy and a 1-hour session token.
Unix sockets + nginx TLS proxy; the proxy is reachable only via WireGuard.
Logs alert if any tool call mentions “cat” or touches /home.
Result: attempted exfiltration could no longer read home dirs, the command tool was allowlisted to a safe binary, and the network path was private.
Your next step (CTA)
Don’t wait for the first “strange tool call” to show up in logs. Pick one MCP server today and:
1) Move it under a dedicated systemd unit with the sandbox flags above.
2) Bind it to a Unix socket and put any remote access behind SSH or a TLS proxy.
3) Pull secrets into a 600-permission EnvironmentFile and rotate them.
4) Add at least two audit rules and one allowlist for tools/args.
If you want a one-page checklist or example profiles for AppArmor/SELinux and Podman, set up a test host and iterate. The sooner your MCP surface is fenced, the safer your LLM-powered workflows will be.
Happy hardening—and if you hit a snag, share your unit files and logs. The Linux community has your back.