← Blog

Claude skill cryptographic implementation security

Claude skills that handle sensitive data — session tokens, encrypted payloads, HMAC-signed webhooks, or content integrity checks — sometimes implement cryptographic operations directly rather than using a well-audited library. The four attack patterns in SA-SKL-CRY cover the most common implementation errors: using Math.random() instead of crypto.randomBytes() for tokens and nonces, CBC-mode encryption with predictable or reused IVs, and weak hash functions that are vulnerable to collision attacks.

By SkillAudit  |  2 Nov 2026

Cryptographic errors in MCP skills are particularly consequential because skills often act as intermediaries between users and sensitive systems. A skill with a forged session token grants an attacker access to any user’s session. A skill with a broken encryption implementation exposes encrypted payloads to decryption. A skill with a weak HMAC nonce allows forged webhook deliveries. Because the cryptographic primitives are already implemented correctly in Node.js’s built-in crypto module, these errors are almost always the result of a developer using a convenient-looking but cryptographically insecure function, or copying a tutorial example that was not designed for production use.

SA-SKL-CRY-001: Math.random() used for session tokens, HMAC nonces, or CSRF tokens

Math.random() is a pseudo-random number generator (PRNG) seeded from the current time. It produces values that are statistically uniform but not cryptographically unpredictable: given enough output values, an attacker can infer the PRNG state and predict future values. Skills that use Math.random() to generate session tokens, API keys, password reset tokens, one-time codes, or HMAC nonces are vulnerable. The attack requires observing a sequence of generated values from the token endpoint (for instance, by registering multiple accounts and collecting the session tokens assigned to each), recovering the PRNG state, and then predicting the token assigned to a target user. The entropy of Math.random() output is also far lower than its apparent 64-bit precision: internal V8 PRNG state is 64 bits, but the actual security-relevant entropy of the seed is far less.

// VULNERABLE: Math.random() for token generation
function generateSessionToken() {
  // Math.random() is NOT cryptographically secure
  return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
  // ^ 2 × ~11 chars ≈ 22 chars ≈ ~114 bits apparent entropy
  // ^ actual security: PRNG state 64 bits; seed from time is ~32 bits of entropy
  // ^ attacker observes 50 tokens → recovers PRNG state → predicts all future tokens
}

// ALSO VULNERABLE: Math.random() for HMAC nonce
function generateWebhookNonce() {
  return Math.floor(Math.random() * 1e16).toString();
}

// ALSO VULNERABLE: Date.now() as token
function generateResetToken() {
  return Date.now().toString(36);  // ← timestamp, not random; guessable to millisecond
}

// ALSO VULNERABLE: custom XorShift or LCG implementation
function lcgRandom(seed) {
  return (seed * 1664525 + 1013904223) & 0xffffffff;  // ← deterministic from seed
}

// SECURE: use crypto.randomBytes() for all security-relevant random values
import { randomBytes } from 'crypto';

function generateSessionToken() {
  return randomBytes(32).toString('hex');
  // ^ 256 bits of OS-sourced entropy → cryptographically unpredictable
  // ^ standard for session tokens (compare: AWS session tokens, OAuth access tokens)
}

function generateWebhookNonce() {
  return randomBytes(16).toString('hex');  // 128-bit nonce
}

function generateResetToken() {
  return randomBytes(32).toString('base64url');  // URL-safe base64, 256-bit entropy
}

// For numeric codes (SMS OTP, PIN):
import { randomInt } from 'crypto';
const otp = randomInt(0, 1_000_000).toString().padStart(6, '0');
// ^ cryptographically secure random integer in [0, 1000000)

// DETECTION (SkillAudit):
// Math.random() in any expression that produces a value used as:
// session token, API key, password reset token, CSRF token, OTP, or HMAC nonce
const weakRandom = searchAST(
  'Math.random() in expression assigned to variable with name matching ' +
  '/token|key|secret|nonce|otp|code|csrf|session|reset|auth/'
);
if (weakRandom.length > 0) → CRITICAL  // SA-SKL-CRY-001

Math.random() state is only 64 bits: the xorshift128+ algorithm used by V8’s Math.random() has a full state of 128 bits, but the outputs are 64-bit floats, and the state can be recovered from approximately 3 consecutive outputs using the lattice basis reduction technique. Academic proof-of-concept implementations recover the V8 PRNG state in under a second. If an attacker can obtain even a handful of tokens generated by your skill, all past and future tokens generated by the same process instance are compromised.

CRITICAL
SA-SKL-CRY-001 — Math.random() for session tokens, HMAC nonces, or CSRF tokens: V8 PRNG state recoverable from ~3 observed outputs; attacker predicts all tokens for the process lifetime. Use crypto.randomBytes() for all security-relevant random values. Detection: Math.random() in token/key/nonce expressions → CRITICAL.

SA-SKL-CRY-002: CBC mode with predictable or sequential IVs enables chosen-plaintext attacks

AES-CBC requires a unique, random IV for every encryption operation. If the IV is predictable — a counter, a timestamp, a constant, or a value derived from the message — an attacker who can request the encryption of chosen plaintexts can determine whether two different plaintexts produce the same ciphertext prefix. This enables chosen-plaintext attacks that can recover plaintext. In the most severe case (CBC with a fixed IV, used to encrypt user-supplied content), the attacker can construct a chosen-plaintext oracle that reveals arbitrary plaintext blocks by observing which two messages produce matching ciphertext blocks. Skills that encrypt user session data, API responses for caching, or webhook payloads with CBC mode must generate a fresh cryptographically random IV per message and prepend it to the ciphertext for use during decryption.

// VULNERABLE: counter-based IV
let ivCounter = 0;
function encrypt(plaintext, key) {
  const iv = Buffer.alloc(16, 0);
  iv.writeUInt32BE(ivCounter++, 12);  // ← predictable sequential IV
  const cipher = createCipheriv('aes-256-cbc', key, iv);
  return Buffer.concat([iv, cipher.update(plaintext), cipher.final()]);
}

// ALSO VULNERABLE: timestamp-based IV
function encrypt(plaintext, key) {
  const iv = Buffer.from(Date.now().toString(16).padStart(32, '0'), 'hex');
  // ← timestamp is guessable to millisecond; not cryptographically random
  const cipher = createCipheriv('aes-256-cbc', key, iv);
  return Buffer.concat([iv, cipher.update(plaintext), cipher.final()]);
}

// ALSO VULNERABLE: derived IV (from plaintext hash)
function encrypt(plaintext, key) {
  const iv = createHash('md5').update(plaintext).digest();
  // ← IV derived from plaintext means identical plaintexts produce identical ciphertexts
  // ← reveals when two messages have the same first block (traffic analysis)
  const cipher = createCipheriv('aes-256-cbc', key, iv);
  return Buffer.concat([iv, cipher.update(plaintext), cipher.final()]);
}

// SECURE: cryptographically random IV per message; or use AES-GCM (preferred)
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';

// PREFERRED: use AES-256-GCM — provides authenticated encryption (no separate HMAC needed)
function encryptGCM(plaintext, key) {
  const iv = randomBytes(12);  // 96-bit IV is standard for GCM
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();  // 128-bit authentication tag
  // Store: iv (12 bytes) + authTag (16 bytes) + ciphertext
  return Buffer.concat([iv, authTag, ciphertext]).toString('base64');
}

function decryptGCM(encoded, key) {
  const data = Buffer.from(encoded, 'base64');
  const iv = data.subarray(0, 12);
  const authTag = data.subarray(12, 28);
  const ciphertext = data.subarray(28);
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(authTag);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
  // Throws if ciphertext was tampered with — authTag verification fails
}

// If you must use CBC (legacy interop): random IV per message
function encryptCBC(plaintext, key) {
  const iv = randomBytes(16);  // cryptographically random IV
  const cipher = createCipheriv('aes-256-cbc', key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  return Buffer.concat([iv, ciphertext]).toString('base64');
  // Add HMAC over (iv + ciphertext) to prevent tampering
}

// DETECTION (SkillAudit):
// createCipheriv('aes-*-cbc', ..., iv) where iv is a counter, timestamp, or constant
const predictableIV = searchAST(
  'createCipheriv("aes-*-cbc", key, iv) where iv derives from ' +
  'counter variable | Date.now() | Buffer.alloc(16, 0) | hardcoded literal'
);
if (predictableIV.length > 0) → HIGH  // SA-SKL-CRY-002

Prefer AES-GCM over AES-CBC for new implementations: AES-GCM (authenticated encryption) provides both confidentiality and integrity in a single primitive, using a 96-bit random nonce rather than a 128-bit IV. It eliminates the need for a separate HMAC computation to detect ciphertext tampering, and the shorter nonce makes collision probability acceptable for up to 2^32 messages per key (approximately 4 billion encryptions). GCM is the recommended mode for all new symmetric encryption in Node.js. Reserve CBC for legacy protocol interoperability only.

HIGH
SA-SKL-CRY-002 — AES-CBC with predictable or sequential IV: chosen-plaintext attack recovers plaintext blocks; identical IVs reveal identical plaintext prefixes. Use AES-GCM with randomBytes(12) nonce for new code. Detection: createCipheriv('aes-*-cbc', ...) with counter/timestamp/constant IV → HIGH.

SA-SKL-CRY-003: createCipheriv with hardcoded or config-file IV reused across encryptions

A specific and widespread instance of the predictable IV problem is skills that read the IV from a config file or environment variable alongside the key. The reasoning is that the IV is “per-deployment secret” rather than per-message random. This is incorrect: the IV must be unique per message, not per deployment. Using the same IV for every encryption with the same key means every message encrypted with that IV/key pair is vulnerable to the same-IV attack: XORing two ciphertexts encrypted with the same IV and key produces the XOR of their plaintexts, allowing an attacker who knows one plaintext to recover the other. In a skill that encrypts user data, knowing the content of any one encrypted message (for instance, your own data) reveals all others encrypted with the same IV/key pair.

// VULNERABLE: IV from environment variable (same IV for every encryption)
const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');   // 32 bytes
const IV  = Buffer.from(process.env.ENCRYPTION_IV, 'hex');    // 16 bytes ← STATIC

function encryptData(data) {
  const cipher = createCipheriv('aes-256-cbc', KEY, IV);
  // ^ same IV every call — two different plaintexts encrypted:
  // C1 = E(P1), C2 = E(P2) with same (K, IV)
  // C1 XOR C2 = P1 XOR P2 — if attacker knows P1, they recover P2 directly
  return Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]).toString('hex');
}

// ALSO VULNERABLE: IV from config.json
import config from './config.json' assert { type: 'json' };
const iv = Buffer.from(config.encryptionIV, 'hex');  // ← static, same for all messages

// ALSO VULNERABLE: IV as module-level constant
const ENCRYPTION_IV = '00112233445566778899aabbccddeeff';  // hardcoded IV
const iv = Buffer.from(ENCRYPTION_IV, 'hex');

// SECURE: generate a new random IV per message; store it with the ciphertext
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';

const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
// Remove IV from environment — it has no place there for per-message encryption

function encryptData(plaintext) {
  const iv = randomBytes(16);  // ← unique per message
  const cipher = createCipheriv('aes-256-cbc', KEY, iv);
  const ciphertext = Buffer.concat([
    cipher.update(plaintext, 'utf8'),
    cipher.final()
  ]);
  // Prepend IV to ciphertext — it is not secret, only must be unique
  return Buffer.concat([iv, ciphertext]).toString('base64');
}

function decryptData(encoded) {
  const buf = Buffer.from(encoded, 'base64');
  const iv = buf.subarray(0, 16);          // extract stored IV
  const ciphertext = buf.subarray(16);     // remaining bytes are ciphertext
  const decipher = createDecipheriv('aes-256-cbc', KEY, iv);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}

// DETECTION (SkillAudit):
// createCipheriv calls where the iv argument is a module-level constant,
// process.env.* value, or config file field (same across all calls)
const staticIV = searchAST(
  'createCipheriv(algo, key, iv) where iv is module-level | process.env.* | config.*'
);
if (staticIV.length > 0) → HIGH  // SA-SKL-CRY-003

The IV does not need to be secret — it needs to be unique: a common misconception is that protecting the IV adds a layer of security. It does not. The security of AES-CBC depends entirely on the key remaining secret and the IV being unique per message. Storing the IV alongside the ciphertext is standard practice (as in TLS record headers) and provides correct security. Storing the IV in environment configuration and reusing it across millions of encryptions, on the other hand, is a critical cryptographic failure.

HIGH
SA-SKL-CRY-003 — createCipheriv with static IV from environment variable or config file: two ciphertexts XOR to XOR of plaintexts; attacker who knows one plaintext recovers all others encrypted with the same IV/key pair. Detection: createCipheriv with process.env.* or module-level IV → HIGH.

SA-SKL-CRY-004: createHash('md5') or createHash('sha1') for security-relevant digest operations

MD5 and SHA-1 are broken cryptographic hash functions. MD5 has been practically collision-broken since 2004 (the Wang-Yu attack); SHA-1 has been practically collision-broken since 2017 (SHAttered). A collision means an attacker can produce two different inputs that produce the same hash output. For security-relevant uses — integrity verification, digital signatures, certificate fingerprinting, and HMAC-based MAC keys — collision resistance is required. Skills that use MD5 or SHA-1 for these purposes allow an attacker to substitute a malicious payload that produces the same hash as a legitimate one, bypassing the integrity check. The practical difficulty of the attack depends on the use case: content-addressable storage keyed on MD5 is trivially exploitable; HMAC-SHA1 is still considered secure for MAC operations (because the HMAC construction provides collision-resistance even with SHA-1) but should still be migrated to HMAC-SHA256 for new code.

// VULNERABLE: MD5 for content integrity check
import { createHash } from 'crypto';

function verifyFileIntegrity(fileBuffer, expectedHash) {
  const actual = createHash('md5').update(fileBuffer).digest('hex');
  // MD5 collision → attacker creates malicious file with same MD5 hash as legitimate file
  // expectedHash = MD5 of legitimate file
  // maliciousFile has same MD5 → passes integrity check
  return actual === expectedHash;
}

// ALSO VULNERABLE: SHA-1 for webhook signature
function verifyWebhookSignature(body, signature, secret) {
  const expected = createHmac('sha1', secret).update(body).digest('hex');
  // HMAC-SHA1 is still secure for MAC, but should be migrated to HMAC-SHA256
  return signature === `sha1=${expected}`;  // ← GitHub legacy webhook format
}

// ALSO VULNERABLE: MD5 for certificate fingerprint comparison
function checkCertFingerprint(cert, expectedFingerprint) {
  const fingerprint = createHash('md5').update(cert.raw).digest('hex');
  // MD5 cert fingerprints can be collided — attacker creates a CA-signed cert
  // with the same MD5 fingerprint as the pinned cert
  return fingerprint === expectedFingerprint;
}

// ALSO VULNERABLE: MD5 for cache keys on user-supplied content
const cacheKey = createHash('md5').update(userContent).digest('hex');
// ^ not directly a security vulnerability, but normalises MD5 usage in codebase
//   and developers may accidentally use the pattern for security-critical paths

// SECURE: use SHA-256 (minimum) or SHA-3 for security-relevant operations
import { createHash, createHmac } from 'crypto';

// File integrity
function verifyFileIntegrity(fileBuffer, expectedHash) {
  const actual = createHash('sha256').update(fileBuffer).digest('hex');
  return actual === expectedHash;
}

// Webhook signatures
function verifyWebhookSignature(body, signature, secret) {
  const expected = createHmac('sha256', secret).update(body).digest('hex');
  // Use timingSafeEqual to prevent timing attacks
  const sig = Buffer.from(signature.replace('sha256=', ''), 'hex');
  const exp = Buffer.from(expected, 'hex');
  return sig.length === exp.length && timingSafeEqual(sig, exp);
}

// Certificate pinning
function checkCertFingerprint(cert, expectedFingerprint) {
  const fingerprint = createHash('sha256').update(cert.raw).digest('hex');
  return timingSafeEqual(
    Buffer.from(fingerprint, 'hex'),
    Buffer.from(expectedFingerprint, 'hex')
  );
}

// DETECTION (SkillAudit):
// createHash('md5' | 'sha1') calls in security-relevant contexts
// (fingerprint comparison, signature verification, integrity check, CSRF token)
const weakHash = searchAST(
  'createHash("md5" | "sha1") where result is used for ' +
  'equality comparison | certificate check | signature verify | CSRF | integrity'
);
if (weakHash.length > 0) → HIGH  // SA-SKL-CRY-004

MD5 and SHA-1 are safe for non-security uses — deduplication, content addressing for caching, and etag generation: the collision weakness matters only when an attacker can choose the input. For uses like “generate a cache key from a config object” or “generate an etag for HTTP caching,” MD5 is fine. The risk is when MD5 or SHA-1 is used to answer a security question: “is this the same file I approved?”, “is this webhook from GitHub?”, or “is this certificate the one I pinned?” Those uses require collision-resistant hashes: SHA-256 or stronger.

HIGH
SA-SKL-CRY-004 — createHash('md5') or createHash('sha1') for integrity checks, fingerprints, or signature verification: MD5 collision attacks create payloads with same digest; SHA-1 SHAttered attack is practical. Use SHA-256 for all security-relevant digests. Detection: createHash('md5'|'sha1') in security comparison context → HIGH.

SA-SKL-CRY summary

IDPatternSeverityImpact
SA-SKL-CRY-001Math.random() for session tokens, nonces, or CSRF tokensCRITICALPRNG state recoverable from ~3 observed tokens; attacker predicts all tokens for process lifetime
SA-SKL-CRY-002AES-CBC with counter, timestamp, or plaintext-derived IVHIGHChosen-plaintext attack; same-IV XOR reveals plaintext; traffic analysis reveals identical prefixes
SA-SKL-CRY-003createCipheriv with static IV from environment or configHIGHAll messages encrypted with same (K, IV) pair; one known plaintext reveals all others via XOR
SA-SKL-CRY-004createHash('md5')/createHash('sha1') for integrity or signaturesHIGHMD5 collision creates matching digest for malicious payload; bypasses integrity check

How SkillAudit detects SA-SKL-CRY patterns

SkillAudit’s analysis identifies all random number generation sites and checks whether the source is crypto.randomBytes()/crypto.randomInt() or an insecure alternative like Math.random(), Date.now(), or custom PRNG implementations (SA-SKL-CRY-001). It locates createCipheriv() calls with CBC mode and traces the IV argument to determine if it is generated fresh per call with randomBytes() or reused from a module-level variable, environment variable, or config file (SA-SKL-CRY-002 and SA-SKL-CRY-003). For SA-SKL-CRY-004, it finds createHash('md5') and createHash('sha1') calls and checks whether their output is used in a security-relevant comparison (equality checks, HMAC verification, certificate pinning).

Run a free SkillAudit scan to check your Claude skill for all four SA-SKL-CRY patterns alongside SA-SKL-TLS TLS certificate validation bypass, SA-SKL-SRL unsafe deserialization, and SA-SKL-ENV credential exposure.