Your MCP Server Has No Audit Trail — A Security Checklist
Last month, an AI agent mass-deleted a production environment. The team spent 3 days piecing together what happened — stderr logs, partial timestamps, no proof of which agent or what parameters. No audit trail.
This isn't rare. Amazon Kiro deleted a prod environment. Replit's agent dropped a live database. Supabase MCP leaked tokens via prompt injection. In every case: zero cryptographic evidence of what happened.
MCP is becoming the standard for agent-tool communication. Claude Code, Cursor, Windsurf, and dozens of tools use it. But the MCP spec ships with:
- ❌ No request signing
- ❌ No audit log
- ❌ No caller identity verification
- ❌ No replay protection
- ❌ No parameter integrity checks
Your MCP server accepts any request from any process, trusts it completely, and keeps no verifiable record. Here's a practical checklist to fix that.
The Threat Model
Before the checklist, understand what you're defending against:
| Attack | How it works | Impact |
|---|---|---|
| Parameter tampering | Agent sends create_issue("fix bug"), something in the pipeline changes it to delete_repo("production")
|
Data loss |
| Replay | Legitimate deploy_to_prod captured and replayed 50 times |
Repeated side effects |
| Impersonation | Rogue process sends requests claiming to be your trusted agent | Unauthorized actions |
| Cross-server forwarding | Request intended for staging gets forwarded to production | Wrong environment |
| Log tampering | Text logs edited after an incident to cover tracks | No incident response |
| Compliance gap | SOC 2 / HIPAA / GDPR require audit trails; "the AI did it" is not sufficient | Regulatory risk |
Checklist
✅ 1. Use TLS for HTTP transports
If your MCP server uses HTTP (SSE or Streamable HTTP), always terminate TLS. This protects data in transit but does not protect against:
- Compromised clients sending bad requests
- Replay attacks (TLS protects the pipe, not the message)
- Log tampering after the fact
For stdio transports (most local MCP servers), TLS doesn't apply — the attack surface is different (any local process can connect).
# nginx example
location /mcp {
proxy_pass http://localhost:3001;
proxy_set_header X-Forwarded-For $remote_addr;
}
Covers: Data in transit.
Doesn't cover: Request integrity, identity, audit.
✅ 2. Validate inputs at the boundary
Every tool handler should validate its arguments. MCP passes arbitrary JSON — treat it like user input.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "create_issue") {
if (typeof args?.title !== "string" || args.title.length > 200) {
return { content: [{ type: "text", text: "Invalid title" }], isError: true };
}
// proceed...
}
});
Use Zod or similar for runtime validation. Never trust args blindly.
Covers: Malformed input, injection.
Doesn't cover: Who sent it, whether it's a replay, audit trail.
✅ 3. Add authentication (API keys or mTLS)
For HTTP transports, require an API key or use mutual TLS:
// Simple API key check
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const apiKey = extra.requestHeaders?.["x-api-key"];
if (apiKey !== process.env.MCP_API_KEY) {
return { content: [{ type: "text", text: "Unauthorized" }], isError: true };
}
// proceed...
});
For stdio, authentication is harder — any local process with access to the pipe can send requests. This is where cryptographic signing becomes necessary.
Covers: Unauthorized callers (HTTP only).
Doesn't cover: Parameter integrity, replay, stdio auth, audit trail.
✅ 4. Sign every request with cryptographic receipts
This is the gap most MCP servers don't address. Signing binds a request to a specific agent identity and makes tampering detectable.
Signet adds Ed25519 signing to MCP. A signed receipt:
{
"v": 1,
"action": {
"tool": "create_issue",
"params_hash": "sha256:b878192...",
"target": "mcp://github.local"
},
"signer": {
"pubkey": "ed25519:0CRkURt/tc6r...",
"name": "deploy-bot"
},
"ts": "2026-04-09T10:30:00.000Z",
"nonce": "rnd_dcd4e13579...",
"sig": "ed25519:6KUohbnS..."
}
Tamper with any field → signature fails. Replay → nonce rejected.
Client side — sign every tool call:
import { SigningTransport } from "@signet-auth/mcp";
const inner = new StdioClientTransport({ command: "my-mcp-server" });
const transport = new SigningTransport(inner, secretKey, "my-agent");
// Every tools/call now carries a signed receipt in params._meta._signet
The receipt is injected into _meta._signet. MCP servers ignore unknown fields by spec — zero server changes needed to start signing. Works with stdio and HTTP.
Server side — verify incoming signatures:
import { verifyRequest, NonceCache } from "@signet-auth/mcp-server";
const nonceCache = new NonceCache();
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const result = verifyRequest(request, {
trustedKeys: ["ed25519:..."], // allowed agent keys
expectedTarget: "mcp://my-server", // anti-forwarding
maxAge: 300, // 5-min freshness window
nonceCache, // replay protection
});
if (!result.ok) {
return { content: [{ type: "text", text: result.error }], isError: true };
}
// Verified: signature valid, signer trusted, fresh, correct target
// proceed with tool execution...
});
In ~50 microseconds, this checks: signature validity, signer trust, freshness, target binding, tool/params integrity, and nonce uniqueness.
Python (works with LangChain, CrewAI, AutoGen, or standalone):
from signet_auth import SigningAgent
agent = SigningAgent.create("my-agent", owner="devops-team")
receipt = agent.sign("create_issue", params={"title": "fix bug"})
assert agent.verify(receipt)
Covers: Identity, parameter integrity, replay, freshness, target binding.
Doesn't cover: Preventing the action (signing is attestation, not policy).
✅ 5. Keep a tamper-evident audit log
Signing individual requests is good. Chaining them into a tamper-evident log is better. If someone deletes or reorders records, the chain breaks.
Signet does this automatically — every signed receipt is appended to a SHA-256 hash-chained JSONL log at ~/.signet/audit/:
record_1: { receipt, prev_hash: "sha256:0000...", record_hash: "sha256:abc1..." }
record_2: { receipt, prev_hash: "sha256:abc1...", record_hash: "sha256:def2..." }
record_3: { receipt, prev_hash: "sha256:def2...", record_hash: "sha256:ghi3..." }
Query and verify from the CLI:
signet audit --since 24h # what happened today
signet audit --tool github --since 7d # github calls this week
signet audit --verify # verify all signatures
signet verify --chain # check hash chain integrity
Or from Python:
for record in agent.audit_query(since="24h"):
print(f"{record.receipt.ts} {record.receipt.action.tool}")
chain = agent.audit_verify_chain()
assert chain.valid
Covers: Tamper detection, incident forensics, compliance audit.
Doesn't cover: Tamper proof (someone with disk access can delete the entire log; off-host anchoring is on the roadmap).
✅ 6. Implement rate limiting and timeouts
Even with signing, a compromised agent can flood your server. Add rate limits:
const callCounts = new Map<string, number>();
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const signer = request.params.arguments?._meta?._signet?.signer?.name ?? "unknown";
const count = (callCounts.get(signer) ?? 0) + 1;
callCounts.set(signer, count);
if (count > 100) { // per-agent limit
return { content: [{ type: "text", text: "Rate limit exceeded" }], isError: true };
}
// proceed...
});
And always set timeouts on tool execution:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
const result = await executeTool(args, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
✅ 7. Principle of least privilege
Don't give your MCP server access to everything. Run it with minimal permissions:
- Separate API keys per tool (read-only key for
list_issues, write key forcreate_issue) - Filesystem access scoped to specific directories
- Database user with only the required grants
- Network egress limited to required endpoints
This is independent of MCP — it's basic defense-in-depth.
Summary
| # | Practice | Protects against | Difficulty |
|---|---|---|---|
| 1 | TLS | Eavesdropping | Easy |
| 2 | Input validation | Injection, malformed data | Easy |
| 3 | Authentication | Unauthorized callers | Medium |
| 4 | Request signing | Tampering, replay, impersonation | 3 lines |
| 5 | Audit log | Incident response, compliance | Automatic with signing |
| 6 | Rate limiting | Denial of service | Easy |
| 7 | Least privilege | Blast radius | Medium |
Most MCP servers today implement 1-3 at best. Steps 4 and 5 — signing and audit — are the gap. They're also the hardest to bolt on after the fact, which is why starting with a library that handles both is worth the npm install.
Get Started
npm install @signet-auth/core @signet-auth/mcp
# or
pip install signet-auth
GitHub: github.com/Prismer-AI/signet
Apache-2.0 + MIT dual licensed. Open source, no SaaS, no phone-home.
If your AI agent can delete a database, you should be able to prove it did.
Top comments (0)