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

See also

Check your MCP server's webhook integration for signature validation, replay prevention, and SSRF findings.

Run a free audit → How grading works →