Topic: mcp server webhook security
MCP server webhook security — HMAC validation, replay prevention, and SSRF
Many MCP servers integrate with external services via webhooks — receiving GitHub push events, Stripe payment confirmations, Slack slash commands, or sending outbound webhook notifications to LLM-specified URLs. Each integration introduces specific security risks: missing HMAC signature validation lets attackers send forged webhook payloads, replay attacks re-send legitimate webhooks captured earlier, SSRF via LLM-controlled callback URLs turns the webhook sender into an SSRF vector, and event type injection sends unexpected event types that bypass routing logic. This page covers the defense for each.
Attack 1: Missing HMAC signature validation
Webhook providers (GitHub, Stripe, Slack, and others) sign their webhook payloads with an HMAC-SHA256 of the raw request body, using a shared secret configured at webhook registration. MCP servers that don't validate this signature will process any request that arrives at the webhook endpoint — including requests forged by attackers who don't know the secret. A forged push event can trigger CI pipelines; a forged payment.succeeded can trigger order fulfillment; a forged Slack command can trigger privileged tool executions.
import crypto from 'crypto'
import express from 'express'
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET!
// WRONG: no signature validation — processes any request
app.post('/webhook/github', express.json(), async (req, res) => {
const event = req.headers['x-github-event'] as string
await handleGitHubEvent(event, req.body)
res.status(200).end()
})
// CORRECT: validate HMAC-SHA256 signature before processing
// CRITICAL: read raw body as Buffer for HMAC computation
// express.json() parses and re-serializes — the re-serialized body may not
// match the original bytes exactly (e.g., key order, whitespace).
// Use express.raw() to get the original bytes.
app.post('/webhook/github', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-hub-signature-256'] as string | undefined
const rawBody = req.body // Buffer — original bytes from the sender
if (!signature) {
return res.status(400).json({ error: 'Missing signature header' })
}
// Compute expected signature from raw bytes + secret
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', GITHUB_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
// Constant-time comparison — prevents timing oracle attacks
// crypto.timingSafeEqual requires Buffers of equal length
const sigBuf = Buffer.from(signature)
const expectedBuf = Buffer.from(expectedSignature)
if (sigBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(sigBuf, expectedBuf)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Only parse the body AFTER signature is verified
const payload = JSON.parse(rawBody.toString('utf8'))
const event = req.headers['x-github-event'] as string
handleGitHubEvent(event, payload)
res.status(200).end()
})
Attack 2: Replay attack via timestamp drift
Webhook HMAC validation proves the payload was signed with the shared secret — but it doesn't prove the payload is fresh. An attacker who intercepts or passively observes a legitimate webhook delivery can replay it minutes, hours, or days later. The HMAC signature will still validate. Most webhook providers include a timestamp in the payload (GitHub uses created_at in the payload; Stripe uses a Stripe-Signature header that includes a t= timestamp component). Replay prevention requires validating this timestamp and tracking used delivery IDs.
import { LRUCache } from 'lru-cache'
// Track used Stripe webhook IDs for 10 minutes (Stripe's max replay window)
const usedWebhookIds = new LRUCache({
max: 10_000,
ttl: 10 * 60 * 1000, // 10-minute TTL matches the timestamp tolerance window
})
// Stripe webhook signature format: "t=timestamp,v1=signature"
function validateStripeWebhook(
rawBody: Buffer,
signatureHeader: string,
secret: string,
webhookId: string
): boolean {
const parts = signatureHeader.split(',')
const timestamp = parts.find(p => p.startsWith('t='))?.slice(2)
const signature = parts.find(p => p.startsWith('v1='))?.slice(3)
if (!timestamp || !signature) return false
// 1. Timestamp freshness check — reject if older than 5 minutes
const ts = parseInt(timestamp, 10)
const ageSeconds = Date.now() / 1000 - ts
if (Math.abs(ageSeconds) > 300) {
throw new Error('Webhook timestamp too old — possible replay attack')
}
// 2. Compute and verify signature
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex')
const sigBuf = Buffer.from(signature)
const expectedBuf = Buffer.from(expected)
if (sigBuf.length !== expectedBuf.length) return false
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return false
// 3. Delivery ID deduplication — reject replays of valid, fresh webhooks
if (usedWebhookIds.has(webhookId)) {
throw new Error('Duplicate webhook delivery ID — replay rejected')
}
usedWebhookIds.set(webhookId, true)
return true
}
Attack 3: SSRF via LLM-controlled webhook callback URL
MCP servers that let the LLM specify where to send webhook notifications — "notify this URL when the job completes" — are SSRF vectors. The LLM, if prompt-injected, can specify an internal URL to exfiltrate data. The webhook delivery function makes an HTTP POST to the attacker-controlled URL, sending the job result (which may contain credentials, file contents, or other sensitive data from the tool's context) to the attacker's server.
// WRONG: LLM-supplied callback URL used directly
async function scheduleJob(args: { callbackUrl: string; jobConfig: unknown }) {
const jobId = await startLongRunningJob(args.jobConfig)
// When job completes, POST result to callbackUrl
// Attack: callbackUrl = 'http://169.254.169.254/latest/meta-data/'
// or callbackUrl = 'http://internal-db:5432/query'
onJobComplete(jobId, async (result) => {
await fetch(args.callbackUrl, {
method: 'POST',
body: JSON.stringify(result),
headers: { 'Content-Type': 'application/json' }
})
})
return { jobId }
}
// CORRECT: validate callback URL against an allowlist before registering
const ALLOWED_CALLBACK_DOMAINS = new Set([
'hooks.example.com',
'api.example.com',
// Add your allowed webhook receiver domains here
])
function validateCallbackUrl(rawUrl: string): URL {
let url: URL
try {
url = new URL(rawUrl)
} catch {
throw new Error('Invalid callback URL format')
}
// Only allow HTTPS — no HTTP, no internal protocols
if (url.protocol !== 'https:') {
throw new Error('Callback URL must use HTTPS')
}
// Block private IP ranges and localhost
const hostname = url.hostname.toLowerCase()
const BLOCKED_HOSTS = ['localhost', '127.0.0.1', '::1', '0.0.0.0']
if (BLOCKED_HOSTS.includes(hostname)) {
throw new Error('Callback URL cannot point to localhost')
}
// Block link-local (AWS IMDS) and private RFC1918 ranges
// In production: use a DNS resolution step to check the resolved IP
const BLOCKED_PREFIXES = ['169.254.', '10.', '172.16.', '192.168.']
if (BLOCKED_PREFIXES.some(p => hostname.startsWith(p))) {
throw new Error('Callback URL cannot point to private IP ranges')
}
// Only allowed domains
if (!ALLOWED_CALLBACK_DOMAINS.has(url.hostname)) {
throw new Error(`Callback domain not in allowlist: ${url.hostname}`)
}
return url
}
async function scheduleJob(args: { callbackUrl: string; jobConfig: unknown }) {
const validatedUrl = validateCallbackUrl(args.callbackUrl) // Throws if invalid
const jobId = await startLongRunningJob(args.jobConfig)
onJobComplete(jobId, async (result) => {
await fetch(validatedUrl.toString(), {
method: 'POST',
body: JSON.stringify(result),
headers: { 'Content-Type': 'application/json' }
})
})
return { jobId }
}
Attack 4: Event type injection bypassing handler routing
Webhook handlers that route events to different functions based on the event type header (e.g., X-GitHub-Event) often only implement handlers for the expected event types. If an attacker can send a webhook with an unexpected event type — and the HMAC validation is missing — they can reach fallback handler code, trigger unintended processing, or bypass security controls that only apply to specific event types.
// WRONG: routes all event types without allowlist validation
async function handleGitHubEvent(event: string, payload: unknown) {
// An unexpected event type might reach the 'default' branch
// and trigger unintended behavior
switch (event) {
case 'push':
await handlePush(payload)
break
case 'pull_request':
await handlePr(payload)
break
default:
// Unexpected event types silently ignored — but what if they're not?
// If 'default' does something (logging, storage, processing),
// an injected event type can trigger it
await logUnknownEvent(event, payload) // May execute arbitrary storage operations
break
}
}
// CORRECT: strict allowlist of expected event types, reject before routing
const ALLOWED_EVENT_TYPES = new Set([
'push',
'pull_request',
'pull_request_review',
'check_run',
'check_suite',
])
async function handleGitHubEvent(event: string, payload: unknown) {
// Reject before any processing — unknown events don't reach any handler
if (!ALLOWED_EVENT_TYPES.has(event)) {
// Log for visibility, but don't process
console.warn(`Rejected unknown webhook event type: ${event}`)
return // Return 200 to prevent webhook retry storms, but do nothing
}
// Now route — all branches are expected, no unexpected default behavior
switch (event) {
case 'push':
await handlePush(payload as PushPayload)
break
case 'pull_request':
await handlePr(payload as PrPayload)
break
// ... other cases
}
}
What SkillAudit checks
- Webhook endpoint that parses the body before verifying HMAC signature — HIGH; signature validation bypass via body re-serialization difference
- Signature comparison using
===instead ofcrypto.timingSafeEqual()— WARN; timing oracle allows signature forgery - No webhook timestamp freshness check — WARN; replay attacks can retrigger past events without time limit
- LLM-supplied URL used as webhook callback destination without allowlist validation — HIGH; SSRF vector with full webhook result body as exfiltration payload
- No event type allowlist before handler routing — WARN; unexpected event types can reach unintended handler code paths
See also
- MCP server SSRF — SSRF patterns in MCP tool handlers
- MCP server input validation — Zod schema validation for tool arguments
- MCP server authentication — authentication for MCP server endpoints
- MCP server request validation — request body and header validation patterns
- MCP server security checklist — comprehensive pre-submission checklist
- Public audit corpus — webhook findings across scanned servers
Check your MCP server's webhook integration for signature validation, replay prevention, and SSRF findings.
Run a free audit → How grading works →