DEV Community

willamhou
willamhou

Posted on

Your MCP Server Has No Audit Trail — A Security Checklist

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;
}
Enter fullscreen mode Exit fullscreen mode

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...
  }
});
Enter fullscreen mode Exit fullscreen mode

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...
});
Enter fullscreen mode Exit fullscreen mode

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..."
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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..." }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

✅ 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 for create_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
Enter fullscreen mode Exit fullscreen mode

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)