Topic: mcp server replay attack security

MCP server replay attack security — preventing signature replay on authenticated tool calls

An MCP server that signs tool call requests to prove authenticity but omits replay prevention allows an attacker to capture a legitimate signed request and re-submit it later — hours or days after it was created — with a valid signature. The server accepts it because the signature is genuine. This is a replay attack: the attacker contributes no cryptographic break, only network access and patience.

Why request signing without replay prevention is insufficient

A cryptographic signature on an MCP tool call request proves exactly one thing: that the request was created by someone who holds the private key. It says nothing about when the request was created, whether it has already been processed, or how many times it should be allowed to execute. A valid HMAC-SHA256 signature on a delete_record or approve_payment request is equally valid the first time it is submitted and the fortieth time — the signature itself carries no uniqueness guarantee.

This matters most for tool calls with non-idempotent side effects. Read-only lookups, executed twice, return the same result twice — annoying but not catastrophic. Financial transfers, record deletions, access-grant operations, and message-send tool calls executed twice cause double-spend or data loss. The attacker does not need to break the signing key; they only need to intercept a single legitimate request and submit it again at a strategically chosen moment.

The attack scenario

An attacker with access to a corporate proxy log, a network tap between the AI agent and the MCP server, or a compromised intermediary service extracts a signed POST body for a financial tool call:

# Captured signed request — extracted from proxy log
POST /tools/call HTTP/1.1
Host: mcp.payments.internal
Content-Type: application/json
X-Signature: sha256=7d4e3f1a2b5c9d8e0f1a2b3c4d5e6f7a8b9c0d1e
X-Signed-At: 1748908800

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "approve_payment",
    "arguments": {
      "amount": 50000,
      "target_account": "CORP-12345",
      "reference": "INV-2026-0601"
    }
  },
  "id": "req-a1b2c3"
}

The attacker stores this captured payload. 48 hours later they replay it via a direct HTTP call to the MCP server. The server's signature verification reads the request body, recomputes HMAC-SHA256 with the shared secret, and the signature matches — because the payload has not changed. The payment executes a second time. The attacker has moved $50,000 using cryptographic proof of a request they did not author.

Nonce-based replay prevention

The authoritative defense against replay attacks is a nonce: a cryptographically random value included in the request that the server records as consumed after first use. Any subsequent request bearing the same nonce is rejected, regardless of whether the signature is valid. The nonce must be generated by the caller (not the server) and must be large enough to be unguessable — 128 bits (16 bytes, 32 hex characters) is the standard minimum.

// Node.js: nonce check with Redis — request processing middleware
import crypto from 'crypto';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const NONCE_TTL_SECONDS = 86_400; // 24 hours — match your replay window

async function enforceNonce(req, res, next) {
  const nonce = req.headers['x-nonce'];

  if (!nonce || nonce.length < 32) {
    return res.status(400).json({ error: 'Missing or short nonce' });
  }

  const key = `nonce:${nonce}`;

  // SETNX: set only if not exists; returns 1 on success, 0 if key already existed
  const set = await redis.set(key, '1', {
    NX: true,
    EX: NONCE_TTL_SECONDS,
  });

  if (set === null) {
    // Key already existed — this nonce was already consumed
    return res.status(409).json({ error: 'Nonce already used — possible replay' });
  }

  next();
}

// Usage: place after signature verification, before tool dispatch
app.post('/tools/call', verifySignature, enforceNonce, dispatchTool);

The Redis SET NX EX operation is atomic: it is impossible for two concurrent requests carrying the same nonce to both pass the check. One will receive null and be rejected. After the TTL expires, the nonce slot is freed — but by then the request is well outside any practical replay window.

Timestamp window — the lighter-weight alternative

A nonce store requires persistent state (Redis or a database). For systems where that is not available, a timestamp window provides significant — though not complete — replay resistance. Each request includes an issued_at Unix timestamp. The server rejects any request whose timestamp falls outside a narrow window (typically ±300 seconds of the server clock). An attacker must replay the request within that 5-minute window or the server rejects it automatically:

// Timestamp validation with clock-skew tolerance
const REPLAY_WINDOW_MS = 300_000;  // 5 minutes
const CLOCK_SKEW_MS    = 30_000;   // 30 seconds tolerance

function validateTimestamp(req, res, next) {
  const issuedAt = parseInt(req.headers['x-issued-at'], 10);

  if (!issuedAt || Number.isNaN(issuedAt)) {
    return res.status(400).json({ error: 'Missing or invalid x-issued-at header' });
  }

  const issuedAtMs = issuedAt * 1000;
  const now = Date.now();

  if (issuedAtMs > now + CLOCK_SKEW_MS) {
    return res.status(400).json({ error: 'Request timestamp is in the future' });
  }

  if (now - issuedAtMs > REPLAY_WINDOW_MS + CLOCK_SKEW_MS) {
    return res.status(400).json({ error: 'Request timestamp expired' });
  }

  next();
}

The limitation: if an attacker can capture and replay a request within the 5-minute window, the timestamp check alone does not stop them. For high-stakes tool calls (financial operations, destructive actions, access grants), timestamp validation must be combined with nonce uniqueness.

Combining both: nonces within a timestamp window

The most practical production configuration uses both controls together. The timestamp window bounds the period during which a nonce must be remembered — instead of storing nonces indefinitely, the server only needs to remember nonces for the window duration. This bounds storage to (request_rate × window_seconds) entries rather than growing without limit:

// Combined: validate timestamp first, then nonce
// Nonces only need to be stored for the window duration
app.post(
  '/tools/call',
  verifySignature,       // HMAC-SHA256 signature check
  validateTimestamp,     // reject stale/future requests
  enforceNonce,          // reject seen nonces (TTL = window duration)
  dispatchTool
);

The order matters: verify the signature first (rejects requests with invalid signatures before touching the nonce store), then validate the timestamp (rejects obviously old requests cheaply), then check the nonce (uses the Redis round-trip only for plausibly fresh, valid-signature requests).

HMAC-SHA256 signing pattern with timestamp and nonce

The signed string should include all request components that must be immutable: the HTTP method, the path, the timestamp, the nonce, and a hash of the body. Signing the body hash prevents an attacker from modifying arguments after the signature was computed:

import crypto from 'crypto';

// Shared between client (AI agent) and MCP server
const SECRET = process.env.MCP_SIGNING_SECRET; // 256-bit random value

function signRequest(method, path, issuedAt, nonce, body) {
  const bodyHash = crypto
    .createHash('sha256')
    .update(JSON.stringify(body))
    .digest('hex');

  const signedString = [method.toUpperCase(), path, issuedAt, nonce, bodyHash]
    .join('\n');

  return crypto
    .createHmac('sha256', SECRET)
    .update(signedString)
    .digest('hex');
}

function verifyRequest(method, path, issuedAt, nonce, body, receivedSig) {
  const expectedSig = signRequest(method, path, issuedAt, nonce, body);
  // Constant-time comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig, 'hex'),
    Buffer.from(receivedSig, 'hex')
  );
}

// Client: build a signed request
const issuedAt = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString('hex');
const body = { jsonrpc: '2.0', method: 'tools/call', params: { name: 'approve_payment', arguments: { amount: 100 } }, id: 'req-1' };
const sig = signRequest('POST', '/tools/call', issuedAt, nonce, body);

// Attach to outgoing HTTP request
headers['X-Issued-At'] = String(issuedAt);
headers['X-Nonce'] = nonce;
headers['X-Signature'] = `sha256=${sig}`;

What SkillAudit checks

SkillAudit's analysis inspects your server's request verification middleware, checks for nonce store integration, validates timestamp extraction and comparison logic, and simulates a replay attempt with a captured request body to verify whether the rejection fires correctly.

Check your MCP server's request authentication for replay prevention gaps.

Run a free audit →