Topic: mcp server cryptography security
MCP server cryptography security — timing-safe comparison, key derivation, nonce management, and HMAC mistakes
Cryptographic vulnerabilities in MCP servers are peculiar: they don't cause functional failures in tests, they don't show up in logs as errors, and they work correctly for legitimate use. What they do is give an attacker an exploitable side channel or bypass that only becomes visible during targeted analysis. Timing-unsafe token comparison, weak key derivation functions, and nonce reuse are the most common findings in SkillAudit's static analysis of MCP server cryptographic implementations.
Timing-unsafe comparison — the most common cryptographic mistake
JavaScript's === operator short-circuits on the first mismatched byte. Comparing a secret token with === leaks timing information: an attacker who can make many requests and measure response time can determine how many bytes of their guess matched, allowing a character-by-character token enumeration attack. This is not theoretical — it has been demonstrated against real webhook validation endpoints:
// Unsafe: === short-circuits on first mismatch
function validateWebhookToken(provided: string, expected: string): boolean {
return provided === expected; // ← timing leak
}
// Also unsafe: early return on length mismatch leaks token length
function validateToken(provided: string, expected: string): boolean {
if (provided.length !== expected.length) return false; // ← leaks length
return provided === expected;
}
// Safe: timingSafeEqual from Node.js crypto module
import { timingSafeEqual } from 'crypto';
function validateWebhookToken(provided: string, expected: string): boolean {
// Both buffers must be the same length for timingSafeEqual
// Pad to a fixed length to avoid leaking token length
const MAX_LEN = 512;
const buf1 = Buffer.alloc(MAX_LEN);
const buf2 = Buffer.alloc(MAX_LEN);
Buffer.from(provided.slice(0, MAX_LEN)).copy(buf1);
Buffer.from(expected.slice(0, MAX_LEN)).copy(buf2);
return timingSafeEqual(buf1, buf2) && provided.length === expected.length;
}
Every comparison of a secret value — webhook signature, API key, session token, HMAC digest — must use timingSafeEqual. This includes internal microservice authentication tokens, not just customer-facing API keys.
Weak HMAC choices — MD5 and SHA1 are broken
HMAC is frequently used in MCP servers for webhook signature validation and API request signing. The security of HMAC depends on the underlying hash function. HMAC-MD5 and HMAC-SHA1 are still in common use because they "work" functionally, but both are considered cryptographically weak for signature applications:
// Weak: HMAC-MD5 or HMAC-SHA1 — collision-prone, weak for signature
import { createHmac } from 'crypto';
function signRequest(payload: string, secret: string): string {
return createHmac('md5', secret).update(payload).digest('hex'); // ← broken
// or: createHmac('sha1', secret).update(payload).digest('hex') // ← weak
}
// Safe: HMAC-SHA256 minimum; HMAC-SHA512 preferred for new implementations
function signRequest(payload: string, secret: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
// For webhook validation, always use timingSafeEqual on the result
function validateSignature(
payload: string,
secret: string,
providedSig: string
): boolean {
const expected = signRequest(payload, secret);
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(providedSig, 'hex')
);
}
Note that HMAC-SHA256 is safe for most MCP server uses regardless of known MD5/SHA1 weaknesses, because the collision attacks on MD5/SHA1 target the hash function's collision resistance, not HMAC's pre-image resistance. However, SkillAudit flags MD5 and SHA1 HMAC uses as MEDIUM findings because the recommendation from NIST SP 800-107 is to migrate to SHA-256 or above for all new implementations.
Nonce reuse in symmetric encryption
If your MCP server encrypts sensitive data at rest (cached credentials, session tokens, user data) using AES-GCM or ChaCha20-Poly1305, nonce reuse is catastrophic. Encrypting two different plaintexts with the same key and nonce under AES-GCM allows an attacker who sees both ciphertexts to recover both plaintexts without knowing the key:
// Dangerous: fixed nonce — encrypts every message with the same IV
const STATIC_IV = Buffer.from('000000000000000000000000', 'hex');
function encrypt(plaintext: string, key: Buffer): Buffer {
const cipher = createCipheriv('aes-256-gcm', key, STATIC_IV); // ← nonce reuse
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}
// Also dangerous: counter nonce that resets on process restart
let nonceCounter = 0;
function encrypt(plaintext: string, key: Buffer): Buffer {
const nonce = Buffer.alloc(12);
nonce.writeUInt32BE(nonceCounter++, 8); // ← resets to 0 on restart
const cipher = createCipheriv('aes-256-gcm', key, nonce);
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}
// Safe: cryptographically random nonce per encryption, prepended to ciphertext
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
const NONCE_BYTES = 12; // 96-bit nonce for AES-GCM
function encrypt(plaintext: string, key: Buffer): Buffer {
const nonce = randomBytes(NONCE_BYTES); // ← fresh random nonce every call
const cipher = createCipheriv('aes-256-gcm', key, nonce);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag(); // 16-byte authentication tag
// Prepend nonce + tag for storage — all required for decryption
return Buffer.concat([nonce, tag, encrypted]);
}
function decrypt(ciphertext: Buffer, key: Buffer): string {
const nonce = ciphertext.subarray(0, NONCE_BYTES);
const tag = ciphertext.subarray(NONCE_BYTES, NONCE_BYTES + 16);
const data = ciphertext.subarray(NONCE_BYTES + 16);
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag);
return decipher.update(data) + decipher.final('utf8');
}
Key derivation — don't use raw password strings as encryption keys
Environment variable secrets and user passwords are not suitable as direct AES-256 keys. They have poor entropy distribution and may be shorter than the required 32 bytes. Use PBKDF2 or scrypt to derive a properly-structured key from a passphrase:
// Dangerous: padding a low-entropy secret to 32 bytes does not improve entropy
const key = Buffer.from(process.env.SECRET!.padEnd(32, '0')); // ← terrible
// Safe: derive a key using scrypt with a per-key salt
import { scryptSync, randomBytes } from 'crypto';
function deriveKey(passphrase: string, salt?: Buffer): { key: Buffer; salt: Buffer } {
const keySalt = salt ?? randomBytes(32);
const key = scryptSync(passphrase, keySalt, 32, { N: 16384, r: 8, p: 1 });
return { key, salt: keySalt };
}
// For application-level secrets (not user passwords), use HKDF instead:
import { hkdfSync } from 'crypto';
function deriveApplicationKey(masterSecret: Buffer, purpose: string): Buffer {
const derived = hkdfSync('sha256', masterSecret, '', purpose, 32);
return Buffer.from(derived);
}
// deriveApplicationKey(masterSecret, 'encryption-at-rest')
// deriveApplicationKey(masterSecret, 'webhook-signing')
// Each purpose produces a distinct key from the same master secret
SkillAudit findings for cryptographic issues
| Finding | Axis | Severity |
|---|---|---|
| Secret/token compared with === or includes() — timing side channel exploitable for character-by-character enumeration | Security | HIGH |
| Static or counter-based nonce used with AES-GCM or ChaCha20 — nonce reuse breaks confidentiality | Security | HIGH |
| HMAC-MD5 used for webhook signature validation or request signing | Security | MEDIUM |
| HMAC-SHA1 used for webhook signature validation or request signing | Security | MEDIUM |
| Environment variable padded or truncated to key length without KDF — low-entropy key | Credentials | MEDIUM |
| Math.random() used for token generation — not cryptographically random | Security | HIGH |
| ECB mode used for block cipher — pattern-preserving, plaintext structure visible in ciphertext | Security | HIGH |
Run a free SkillAudit scan to check your MCP server for cryptographic implementation mistakes. SkillAudit's static analysis detects timing-unsafe comparisons, weak hash algorithms, and static nonce patterns at the source code level — before they reach production.