
If you're running an MCP server between a Claude Code agent and any backend tool, that channel is unauthenticated by default. Any middleware — a proxy, a compromised container, or an active attacker — can silently rewrite the request body before it reaches your server. The server has no way to detect it.
This post shows you exactly how to bolt HMAC-SHA256 request signing onto an MCP server setup: one signing function on the client, one verification function on the server, and a replay-attack window of 30 seconds. Tested on Mac Mini M4, Python 3.12, httpx 0.27. No new dependencies.
The Problem: A Hash Alone Is Not Enough
The naive first attempt at MCP integrity is to SHA-256 hash the request body and send it along. I tried this. It doesn't work, and here's exactly why.
SHA-256 tells you whether a body changed — but only if you trust the hash itself. If an attacker rewrites the body, they recompute the hash too, and your server sees a perfectly consistent (and completely forged) pair. There's no way to tell the difference.
# ❌ Broken approach — attacker can re-hash
import hashlib, json
body = json.dumps({"tool": "read_file", "path": "/data/report.md"}).encode()
checksum = hashlib.sha256(body).hexdigest()
# attacker swaps path → /etc/passwd, recomputes checksum, done
The server still processes the tampered path. No alarm.
HMAC solves this by mixing a shared secret key into the digest. Without the key, an attacker can't produce a valid signature — rewriting the body and trying to forge the HMAC just outputs garbage.
The Fix: HMAC Signing on Both Ends
Client side: sign before you send
The signing function needs to do three things: serialize the payload deterministically, attach a current Unix timestamp, and compute HMAC-SHA256(secret, timestamp + "." + body).
import hmac, hashlib, time, json, httpx
SECRET = b"mcp-shared-secret-256bit"
def sign_request(payload: dict) -> dict:
body = json.dumps(payload, separators=(',', ':')).encode()
ts = str(int(time.time()))
mac = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return {"X-MCP-Timestamp": ts, "X-MCP-Signature": f"sha256={mac}"}
payload = {"tool": "read_file", "path": "/data/report.md"}
headers = sign_request(payload)
response = httpx.post("http://localhost:8080/mcp", json=payload, headers=headers)
Two things to note here. First, separators=(',', ':') strips whitespace from the JSON — this gives you a canonical byte sequence so the client and server hash the exact same bytes. If you skip this, you'll get intermittent mismatches whenever JSON serializers differ in whitespace handling.
Second, the timestamp is inside the HMAC input, not just a separate header. This means an attacker can't strip or replace the timestamp without invalidating the signature.
Server side: verify before you do anything
The verification runs at the top of your request handler, before any tool logic executes.
import hmac, hashlib, time
MAX_DRIFT_SEC = 30 # replay attack window
def verify_request(headers: dict, body: bytes) -> bool:
ts = headers.get("X-MCP-Timestamp", "")
sig = headers.get("X-MCP-Signature", "").removeprefix("sha256=")
if abs(int(time.time()) - int(ts)) > MAX_DRIFT_SEC:
return False # stale request — replay blocked
expected = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) # constant-time compare
The MAX_DRIFT_SEC = 30 check kills replay attacks. An attacker who captures a valid signed request can't resend it 31 seconds later — the timestamp comparison fails first.
hmac.compare_digest is non-negotiable. Plain == on strings short-circuits as soon as it finds a mismatch, which means the response time leaks information about how far into the signature the mismatch occurred. Given enough requests, a timing attacker can reconstruct the expected signature one character at a time. compare_digest always runs in constant time.
Wire it into your FastAPI/Flask handler
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/mcp")
async def handle_mcp(request: Request):
body = await request.body()
if not verify_request(dict(request.headers), body):
raise HTTPException(status_code=403, detail="Signature mismatch")
payload = json.loads(body)
# ... dispatch to tool handler
That's it. The gate is at the top, everything else is unchanged.
Variations and Gotchas
Canonical JSON serialization is load-bearing
If your client and server use different JSON libraries, or different Python versions that serialize floats differently, the body bytes won't match and every request fails with 403. The safest fix:
# ✅ Client and server both use this
body_bytes = json.dumps(payload, separators=(',', ':'), sort_keys=True).encode()
sort_keys=True is worth adding if your payload dicts might be constructed in different orders across environments.
Environment differences
| Environment | Gotcha | Fix |
|---|---|---|
| Docker (no NTP) | Clock drift can exceed 30s | Mount /etc/localtime, set MAX_DRIFT_SEC=60 temporarily during setup |
| macOS dev vs Linux prod | Same Python behavior | No issue if using stdlib hmac |
| Multiple agents | Each shares the same secret | Fine for single-tenant; for multi-tenant, key per agent |
| Streaming responses | Body arrives chunked | Buffer full body before verifying — don't stream into verify_request |
What about HTTPS?
HMAC signing and TLS solve different problems. TLS encrypts the channel and authenticates the server (via certificate). HMAC authenticates the request body and the caller. In a zero-trust internal network where TLS terminates at a load balancer, HMAC catches tampering that happens after termination. Use both.
Rotating the secret
Your shared secret should rotate. The practical pattern: include a key ID in the signature header, keep the last two active keys on the server side.
# Header: X-MCP-Signature: sha256=<hex>;kid=v2
# Server maintains: SECRETS = {"v1": b"...", "v2": b"..."}
This lets you rotate without downtime — old agents still present kid=v1 until you cut them over.
Before vs. after: what actually happened in my test
Before adding HMAC, I ran a local proxy that replaced "path": "/data/report.md" with "path": "/etc/passwd" in the request body. The MCP server processed it, no complaint.
After adding the signing layer, the same proxy swap caused an immediate 403 Signature mismatch. One byte changed in the body, the HMAC diverged completely. The server never touched the tool handler.
Total implementation time: ~40 minutes. Code added: under 50 lines. External packages added: 0.
On my Mac Mini M4, signature verification overhead averages 0.3ms per request — imperceptible in any real workload.
Closing
The MCP channel between your agent and tool server is unauthenticated out of the box. HMAC-SHA256 with a timestamp closes two attack classes simultaneously — body tampering and replay — in under 50 lines of stdlib Python.
The one pattern I'd push hardest: add sort_keys=True to your JSON serializer on both ends from day one. That's the gotcha most people hit on day two when they start testing across environments. Everything else in the implementation is straightforward once the canonical serialization is locked down.
Next up: key-per-agent scoping, so a compromised agent can only tamper with its own request class, not every tool on the server.
🐦 Faster updates on X: @baegseungh7061
📚 More in this series: Code Advanced
💌 Subscribe: Follow on X or grab the RSS
댓글
댓글 쓰기