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:
- Legitimate retries: the LLM agent retries a tool call because the first response was slow or ambiguous. Without idempotency, both calls execute.
- Replay attacks: an attacker captures a valid tool call (from logs, from network interception, or from the LLM's context) and re-issues it with the same arguments. The server re-executes without knowing the call was repeated.
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
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