MCP Security · Replay Protection

MCP server idempotency key security: replay protection for write tools

Idempotency keys let clients safely retry failed write operations — but they also create a replay attack surface if not properly scoped. Reusing keys across callers, accepting expired keys, or generating predictable keys all enable attackers to re-execute write operations they were only authorized to perform once. This page covers secure idempotency key design for MCP write tools.

Why write tools need replay protection

MCP write tools that trigger real-world effects — creating charges, sending messages, transferring records, provisioning resources — must be protected against double-execution. Double-execution causes duplicate charges, duplicate notifications, or conflicting state writes depending on the tool.

Two mechanisms cause double-execution in MCP contexts:

Attack 1: idempotency key reuse across callers

Many MCP servers implement idempotency naively — they store the key in a global table and return the cached result on repeat submission. The flaw: no binding between the key and the caller who submitted it:

// VULNERABLE: global key lookup, no caller binding
async function createCharge({ amount, idempotency_key }) {
  const cached = await db.query(
    'SELECT result FROM idempotency_cache WHERE key = $1', [idempotency_key]
  );
  if (cached) return cached.result;  // returns ANY caller's cached result

  const result = await stripe.charges.create({ amount });
  await db.query(
    'INSERT INTO idempotency_cache (key, result) VALUES ($1, $2)',
    [idempotency_key, result]
  );
  return result;
}

Attack: attacker submits create_charge with a guessed or observed idempotency key that a legitimate caller previously used. The server returns the cached result without running a new charge — but the attacker now has a valid charge record they can use as proof-of-payment for the cached amount.

The fix: bind keys to caller identity:

// SECURE: key namespaced to caller
const namespacedKey = `${ctx.session.callerId}:${idempotency_key}`;
const cached = await db.query(
  'SELECT result FROM idempotency_cache WHERE key = $1', [namespacedKey]
);

Attack 2: predictable key pre-computation

Some clients derive idempotency keys from predictable inputs: timestamps, sequential integers, or UUIDs seeded from the current time. An attacker who can predict the key for a future write call can pre-register it — causing the legitimate call to use the attacker's pre-seeded cached result:

// VULNERABLE: predictable key generation
const idempotencyKey = `charge_${Date.now()}`;  // millisecond timestamp is predictable

// SECURE: cryptographically random key, client-generated
const idempotencyKey = crypto.randomBytes(32).toString('hex');

Server-side defense: reject keys that don't meet minimum entropy requirements:

function validateIdempotencyKey(key: string) {
  if (!/^[a-f0-9]{64}$/.test(key)) {
    throw new ValidationError(
      'idempotency_key must be a 64-character lowercase hex string (32 bytes of entropy). ' +
      'Use crypto.randomBytes(32).toString("hex") to generate.'
    );
  }
}

Attack 3: expired key replay

If idempotency keys are cached indefinitely, a key from six months ago is still replayable. An attacker who obtains old tool call logs (from a database dump, a misconfigured log export, or an insider) can replay past write operations long after the legitimate caller's session expired:

// SECURE: TTL-bounded idempotency window
const IDEMPOTENCY_TTL_SECONDS = 86400;  // 24 hours

const cached = await db.query(
  `SELECT result FROM idempotency_cache
   WHERE key = $1 AND submitted_at > NOW() - INTERVAL '${IDEMPOTENCY_TTL_SECONDS} seconds'`,
  [namespacedKey]
);

// On insert: always store submitted_at
await db.query(
  'INSERT INTO idempotency_cache (key, caller_id, result, submitted_at) VALUES ($1, $2, $3, NOW())',
  [namespacedKey, callerId, result]
);

Attack 4: argument mismatch replay

An idempotency key should be bound not just to the caller but to the original arguments. If the server allows the same key to be used with different arguments, an attacker can replay a key to substitute different amounts or recipients:

// SECURE: bind key to argument hash
import { createHash } from 'crypto';

function argsHash(args: Record): string {
  return createHash('sha256')
    .update(JSON.stringify(args, Object.keys(args).sort()))
    .digest('hex');
}

// On first submission: store arg hash
await db.query(
  'INSERT INTO idempotency_cache (key, caller_id, args_hash, result, submitted_at) VALUES ($1,$2,$3,$4,NOW())',
  [namespacedKey, callerId, argsHash(args), result]
);

// On repeat submission: verify args match
const cached = await db.query(
  'SELECT result, args_hash FROM idempotency_cache WHERE key=$1 AND caller_id=$2', [namespacedKey, callerId]
);
if (cached && cached.args_hash !== argsHash(args)) {
  throw new ConflictError(
    'idempotency_key reuse with different arguments is not permitted. Use a new key for different calls.'
  );
}

Complete secure idempotency implementation

interface IdempotencyRecord {
  key: string;
  callerId: string;
  argsHash: string;
  result: unknown;
  submittedAt: Date;
}

async function withIdempotency(
  ctx: MCPContext,
  key: string,
  args: Record,
  execute: () => Promise
): Promise {
  validateIdempotencyKey(key);
  const namespacedKey = `${ctx.session.callerId}:${key}`;
  const hash = argsHash(args);

  const existing = await db.idempotencyCache.findOne({
    key: namespacedKey,
    submittedAt: { $gt: new Date(Date.now() - 86400_000) },
  });

  if (existing) {
    if (existing.argsHash !== hash) {
      throw new ConflictError('Key already used with different arguments');
    }
    return existing.result as T;  // safe replay
  }

  const result = await execute();

  await db.idempotencyCache.insertOne({
    key: namespacedKey,
    callerId: ctx.session.callerId,
    argsHash: hash,
    result,
    submittedAt: new Date(),
  });

  return result;
}

SkillAudit findings for idempotency key vulnerabilities

HIGHIdempotency key lookup not bound to caller identity — key reuse across callers returns cached results for unauthorized principals
HIGHNo argument hash binding on idempotency keys — same key accepted with different args, enabling substitution replay
MEDIUMIdempotency keys cached indefinitely — old tool call logs replayable without expiry window
MEDIUMNo minimum entropy validation on idempotency keys — predictable keys (timestamps, sequential IDs) accepted
LOWWrite tools without idempotency keys — retries cause double-execution; not a security issue but a reliability gap that affects grade

Run a free SkillAudit to check whether your MCP write tools implement caller-bound, argument-hashed, TTL-windowed idempotency. Paste your GitHub URL →

Related: rate limiting deep dive · tool chaining attacks · input validation patterns