← Blog

Claude skill webhook and callback security

Webhooks turn a skill from a request-response service into a long-running event subscriber — and every webhook introduces two attack surfaces: the URL the skill calls (SSRF risk) and the URL the skill exposes to receive callbacks (forgery risk). MCP skills that register webhooks must treat both surfaces with the same scrutiny as any external API call.

By SkillAudit  |  3 Nov 2026

MCP skills that integrate with event-driven external services — payment processors, CI systems, source control, communication platforms — routinely register webhooks to receive asynchronous callbacks. The skill provides a URL that the external service will POST to when an event occurs, and optionally accepts a caller-supplied callback URL to deliver results to. Both directions carry security obligations. A callback URL accepted from tool arguments and forwarded without validation is a server-side request forgery (SSRF) primitive: the skill will make an HTTP request to any address the attacker names, including cloud metadata endpoints and internal services unreachable from outside the VPC. An inbound webhook handler that skips the HMAC signature included by services like Stripe, GitHub, and Slack accepts forged events from any requester. Webhooks that are never cleaned up silently accumulate and may deliver sensitive data to recycled endpoints belonging to future tenants. And callback URLs that appear in logs expose OAuth authorization codes that are valid at the moment of log ingest.

SA-SKL-NET-001: Unvalidated webhook URL from tool arguments

Skills that accept a callbackUrl or webhookUrl parameter in tool arguments and then make an outbound HTTP request to that URL are vulnerable to server-side request forgery. The skill executes from inside the deployment environment — a VPC, a Kubernetes cluster, a cloud function runtime — where it has network access to services that are not reachable from the public internet. An attacker who controls the callback URL can name any address that is reachable from the skill process: AWS and GCP cloud metadata endpoints (169.254.169.254), internal databases, caches, message queues, admin APIs, and other microservices. The skill will faithfully POST the result payload to the attacker-supplied address, exfiltrating data or triggering state changes in internal services. Critically, the attacker does not need to compromise any authentication credential: the skill itself is the authorized caller making the request.

// VULNERABLE: callbackUrl accepted from tool arguments without validation
import { createServer } from '@anthropic-ai/mcp-sdk/server';

server.setToolHandler('run_analysis_with_callback', async (args) => {
  const result = await runAnalysis(args.dataId);

  // args.callbackUrl is attacker-controlled — skill makes request to any address
  await fetch(args.callbackUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(result)
  });
  // Attacker passes: callbackUrl: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
  // → skill POSTs analysis result to AWS metadata endpoint; response may include IAM role credentials
  // Attacker passes: callbackUrl: 'http://internal-redis:6379/CONFIG SET requirepass hacked'
  // → skill sends HTTP request to Redis admin port inside the VPC

  return { status: 'delivered' };
});

// ALSO VULNERABLE: webhookUrl accepted and registered with an external service
// The external service then calls the attacker-controlled URL on future events
server.setToolHandler('register_payment_webhook', async (args) => {
  await stripeClient.webhookEndpoints.create({
    url: args.webhookUrl,       // ← attacker-controlled; receives payment events
    enabled_events: ['payment_intent.succeeded'],
  });
  return { registered: true };
});

// SECURE: validate URL against allowlist before making any outbound request
import { URL } from 'url';
import dns from 'dns/promises';

const ALLOWED_CALLBACK_HOSTS = new Set([
  'hooks.example.com',
  'webhooks.customer-domain.com',
]);

// RFC1918 private ranges + loopback + link-local + cloud metadata
const BLOCKED_RANGES = [
  /^10\.\d+\.\d+\.\d+$/,
  /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
  /^192\.168\.\d+\.\d+$/,
  /^127\.\d+\.\d+\.\d+$/,
  /^169\.254\.\d+\.\d+$/,   // link-local + AWS/GCP metadata
  /^::1$/,
  /^fc00:/i,
  /^fe80:/i,
];

async function validateCallbackUrl(raw: string): Promise {
  let parsed: URL;
  try { parsed = new URL(raw); } catch { throw new Error('Invalid URL'); }

  // HTTPS only — no HTTP, no file://, no ftp://
  if (parsed.protocol !== 'https:') throw new Error('Callback URL must use HTTPS');

  // Allowlist check — hostname must be a known, safe destination
  if (!ALLOWED_CALLBACK_HOSTS.has(parsed.hostname)) {
    throw new Error(`Callback host not in allowlist: ${parsed.hostname}`);
  }

  // DNS rebinding protection — resolve and check the resulting IP
  const addresses = await dns.resolve4(parsed.hostname).catch(() => []);
  for (const addr of addresses) {
    if (BLOCKED_RANGES.some(re => re.test(addr))) {
      throw new Error(`Callback URL resolves to private IP: ${addr}`);
    }
  }
}

server.setToolHandler('run_analysis_with_callback', async (args) => {
  await validateCallbackUrl(args.callbackUrl);  // throws if invalid

  const result = await runAnalysis(args.dataId);
  await fetch(args.callbackUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(result)
  });
  return { status: 'delivered' };
});

// DETECTION (SkillAudit):
// fetch(args.callbackUrl | args.webhookUrl | args.url) without URL validation
// OR new URL(args.*) without hostname allowlist check AND DNS resolution check
const ssrfViaCallback = searchAST(
  'fetch(args.callbackUrl) | fetch(args.webhookUrl) | fetch(args.url) ' +
  'without validateCallbackUrl() OR allowlist check'
);
if (ssrfViaCallback.length > 0) → CRITICAL  // SA-SKL-NET-001

SSRF via callback URL is a VPC escape: the skill runs inside your network perimeter and the outbound HTTP request it makes carries the implicit trust of that position. An attacker who can name the destination URL does not need credentials — the skill is already authorized. Cloud metadata endpoints at 169.254.169.254 return IAM role credentials, instance identity documents, and user data scripts with no authentication. An allowlist plus DNS rebinding protection (resolve the hostname and check the resulting IP against RFC1918/link-local ranges) is the minimum safe gate. Accept only HTTPS and only to known hosts; reject anything that resolves to a private IP even if the hostname looks legitimate.

CRITICAL
SA-SKL-NET-001 — Unvalidated webhook URL: fetch(args.callbackUrl) without hostname allowlist and DNS rebinding check; skill makes requests to any address including cloud metadata endpoints and internal VPC services. Detection: fetch(args.callbackUrl) without validateCallbackUrl() → CRITICAL.

SA-SKL-NET-002: Webhook payload signature not verified

External services that deliver webhook events — Stripe for payments, GitHub for repository events, Slack for app interactions — protect against forged deliveries by including an HMAC signature in the request headers. Stripe sends Stripe-Signature, GitHub sends X-Hub-Signature-256, and Slack sends X-Slack-Signature. The signature is computed over the raw request body using a webhook secret that only the service and the receiver know. A skill that processes the incoming req.body without verifying this signature will accept forged event payloads from any requester who discovers the webhook endpoint URL. Since webhook URLs are often logged, shared in documentation, or observable in browser network traffic, treating the URL as a secret is not sufficient. Signature verification is the only mechanism that proves the payload was generated by the legitimate service.

// VULNERABLE: webhook handler processes req.body without signature verification
import express from 'express';
const app = express();
app.use(express.json());  // ← parses body as JSON before handler sees it

app.post('/webhooks/stripe', async (req, res) => {
  // No signature check — any POST to this URL is processed as a real Stripe event
  const event = req.body;

  if (event.type === 'payment_intent.succeeded') {
    // Attacker can forge this: POST { type: 'payment_intent.succeeded', data: { object: { amount: 9999 } } }
    await fulfillOrder(event.data.object);
  }

  res.json({ received: true });
});

// ALSO VULNERABLE: checking only event type without verifying it came from Stripe
app.post('/webhooks/github', async (req, res) => {
  const event = req.headers['x-github-event'];
  // Header is trivially forged by attacker — no HMAC verification
  if (event === 'push') {
    await triggerDeployment(req.body.repository.full_name, req.body.after);
  }
  res.sendStatus(200);
});

// SECURE: verify Stripe HMAC signature using raw body (not parsed JSON)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

// CRITICAL: body must be raw Buffer — parsed JSON changes whitespace and fails HMAC
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];

  let event: Stripe.Event;
  try {
    // constructEvent verifies HMAC-SHA256 over rawBody + timestamp in sig header
    // Also rejects events older than 300 seconds (replay protection)
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'payment_intent.succeeded') {
    await fulfillOrder(event.data.object as Stripe.PaymentIntent);
  }

  res.json({ received: true });
});

// SECURE: GitHub HMAC-SHA256 webhook verification
import crypto from 'crypto';

function verifyGitHubSignature(rawBody: Buffer, signature: string, secret: string): boolean {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  // Timing-safe comparison to prevent timing oracle on signature value
  if (expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post('/webhooks/github', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['x-hub-signature-256'] as string;
  if (!sig || !verifyGitHubSignature(req.body, sig, process.env.GITHUB_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const payload = JSON.parse(req.body.toString());  // parse AFTER verification
  if (req.headers['x-github-event'] === 'push') {
    await triggerDeployment(payload.repository.full_name, payload.after);
  }
  res.sendStatus(200);
});

// DETECTION (SkillAudit):
// POST /webhooks/* handler that reads req.body without stripe.webhooks.constructEvent()
// OR without verifyGitHubSignature() / verifySlackSignature() pattern
const unsignedWebhook = searchAST(
  'app.post("/webhooks/*") AND req.body accessed ' +
  'AND NOT constructEvent() | verifyGitHubSignature() | timingSafeEqual()'
);
if (unsignedWebhook.length > 0) → HIGH  // SA-SKL-NET-002

Raw body is required for HMAC verification to succeed: a common implementation mistake is to register express.json() middleware globally and then call stripe.webhooks.constructEvent(req.body, sig, secret) with the already-parsed object. The HMAC is computed over the raw bytes of the HTTP body; JSON serialization of the parsed object may differ in whitespace, key order, or number formatting from the original bytes, causing the signature check to always fail. The correct pattern is to use express.raw({ type: 'application/json' }) on the specific webhook route before the signature check, and parse the body to JSON only after the signature has been successfully verified.

HIGH
SA-SKL-NET-002 — Webhook signature not verified: req.body processed without stripe.webhooks.constructEvent() or equivalent HMAC check; any requester can forge event payloads. Body must be raw Buffer, not parsed JSON, for HMAC to match. Detection: webhook route accesses req.body without signature verification → HIGH.

SA-SKL-NET-003: Webhook registration without expiry

MCP skills operate within the scope of a user session: the LLM invokes tools, the skill executes them, and the session eventually ends. A skill that registers a webhook with an external service during the session but performs no cleanup when the session ends creates a persistent subscription that outlives its authorization. The registered webhook URL continues to receive event callbacks — payment confirmations, CI build results, repository push notifications, calendar alerts — after the session user has logged out, after the skill server may have restarted, and potentially after the URL has been assigned to a different tenant in a multi-tenant deployment. Delivery to a dead endpoint wastes service egress and may trigger delivery-retry queues that affect latency across the external service. Delivery to a recycled URL at a future tenant is a data disclosure: that tenant receives another user’s payment events or CI outputs with no legitimate relationship to them.

// VULNERABLE: webhook registered with no expiry and no session cleanup
import { createServer } from '@anthropic-ai/mcp-sdk/server';
import { Octokit } from '@octokit/rest';

server.setToolHandler('watch_repository', async (args) => {
  const octokit = new Octokit({ auth: args.githubToken });

  // Registers a webhook that will fire on every push to this repo — forever
  const hook = await octokit.repos.createWebhook({
    owner: args.owner,
    repo: args.repo,
    config: {
      url: `https://skill.example.com/webhooks/github`,
      content_type: 'json',
      secret: process.env.GITHUB_WEBHOOK_SECRET,
    },
    events: ['push', 'pull_request'],
    active: true,
    // No expires_at — this webhook fires indefinitely after the session ends
  });

  // Webhook ID is not stored in session state — no way to clean up on session end
  return { watching: true };
  // After session ends: push events keep arriving at /webhooks/github
  // If the skill server is restarted, new tenant may receive prior user's repo events
});

// ALSO VULNERABLE: no deregistration tool provided to the LLM
// LLM cannot clean up what it cannot see; if tool list has no 'unwatch_repository',
// the webhook can never be removed through the MCP interface

// SECURE: store webhook ID in session state; register with expiry where supported
import type { Session } from './session';

server.setToolHandler('watch_repository', async (args, context) => {
  const octokit = new Octokit({ auth: args.githubToken });
  const session: Session = context.session;

  const expiresAt = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); // 4-hour session max

  const hook = await octokit.repos.createWebhook({
    owner: args.owner,
    repo: args.repo,
    config: {
      url: `https://skill.example.com/webhooks/github/${session.id}`,
      // Session-scoped URL — 404s after session cleanup, limiting zombie delivery
      content_type: 'json',
      secret: process.env.GITHUB_WEBHOOK_SECRET,
    },
    events: ['push', 'pull_request'],
    active: true,
  });

  // Store webhook ID in session state for cleanup
  session.webhooks = session.webhooks ?? [];
  session.webhooks.push({
    provider: 'github',
    hookId: hook.data.id,
    owner: args.owner,
    repo: args.repo,
    registeredAt: new Date().toISOString(),
    expiresAt,
  });

  return { watching: true, hookId: hook.data.id, expiresAt };
});

// SECURE: cleanup tool — deregisters all webhooks registered during this session
server.setToolHandler('cleanup_webhooks', async (args, context) => {
  const session: Session = context.session;
  const results: string[] = [];

  for (const wh of session.webhooks ?? []) {
    if (wh.provider === 'github') {
      const octokit = new Octokit({ auth: args.githubToken });
      await octokit.repos.deleteWebhook({ owner: wh.owner, repo: wh.repo, hook_id: wh.hookId })
        .catch(err => results.push(`Failed to delete ${wh.hookId}: ${err.message}`));
      results.push(`Deleted GitHub webhook ${wh.hookId} for ${wh.owner}/${wh.repo}`);
    }
  }

  session.webhooks = [];
  return { cleaned: results };
});

// DETECTION (SkillAudit):
// createWebhook() | webhookEndpoints.create() in tool handler
// AND no corresponding deleteWebhook | webhookEndpoints.del tool registered
// AND webhook ID not stored in session state
const zombieWebhook = searchAST(
  'createWebhook() | webhookEndpoints.create() ' +
  'AND NOT session.webhooks.push(hookId) ' +
  'AND NOT deleteWebhook | webhookEndpoints.del tool exists'
);
if (zombieWebhook.length > 0) → HIGH  // SA-SKL-NET-003

Session-scoped webhook URLs reduce zombie blast radius: include the session ID in the webhook URL path (e.g. /webhooks/github/{sessionId}) so that after session cleanup, the handler can return 404 for any delivery to an expired session URL. This limits the delivery window even if explicit deregistration fails. Where the external service supports expires_at in webhook registration (some payment APIs do), set it to the maximum expected session duration. For services that do not support expiry, the cleanup tool is mandatory, and the LLM should be instructed to call it at session end as part of tool use patterns.

HIGH
SA-SKL-NET-003 — Webhook without expiry or cleanup: createWebhook() with no session-state storage of the hook ID and no complementary delete tool; zombie webhooks accumulate, delivering events to dead or recycled endpoints after session end. Detection: webhook creation without cleanup tool and session-state tracking → HIGH.

SA-SKL-NET-004: Callback URL logged — credential-bearing OAuth redirect URIs in log aggregation

A skill that logs the incoming callbackUrl or redirectUri parameter at the point of registration exposes that value to every system downstream in the log pipeline: stdout ingested by the container orchestrator, a centralized log aggregator, a SIEM, a third-party observability platform, and any developer who queries logs for debugging. OAuth redirect URIs are particularly sensitive because they frequently arrive with authorization codes or state parameters already attached: after the authorization server redirects the user, the browser hits the callback URL with ?code=4/0A...&state=... appended. Authorization codes are short-lived (typically 60–300 seconds) but are valid at the moment of log ingest, which may be within that window. The code can be exchanged by whoever retrieves it from the log for an access token before it expires. Even after expiry, the logged URL reveals the callback endpoint structure, the OAuth provider in use, and state parameters that may encode session or tenant identifiers — all useful for constructing phishing redirects.

// VULNERABLE: callbackUrl logged verbatim — exposes authorization codes and endpoint structure
import express from 'express';
import winston from 'winston';

const logger = winston.createLogger({ transports: [new winston.transports.Console()] });

app.post('/mcp/register-webhook', async (req, res) => {
  const { callbackUrl, eventType } = req.body;

  // Logs the full URL including any query parameters — OAuth codes if present
  logger.info('Webhook registered', { callbackUrl, eventType });
  // Log line: { callbackUrl: 'https://app.example.com/auth/callback?code=4/0AX4XfWj...&state=abc123', eventType: 'push' }
  // ↑ authorization code visible in every log aggregation system with access to this log

  await registerWebhook(callbackUrl, eventType);
  res.json({ status: 'registered' });
});

// ALSO VULNERABLE: debug logging of args that includes callbackUrl
server.setToolHandler('subscribe_to_events', async (args) => {
  // developer added debug logging for troubleshooting — args contains callbackUrl
  logger.debug('Tool args', { args });
  // If args.callbackUrl is 'https://app.example.com/oauth/callback?code=AUTH_CODE&state=XYZ',
  // the authorization code is now in production logs accessible to all log readers

  return await subscribeToEvents(args.callbackUrl, args.eventTypes);
});

// SECURE: redact query parameters from callback URLs before logging
function redactUrl(rawUrl: string): string {
  try {
    const parsed = new URL(rawUrl);
    // Preserve scheme + host + path; strip query parameters and fragment
    return `${parsed.protocol}//${parsed.host}${parsed.pathname}[query redacted]`;
  } catch {
    return '[invalid URL]';
  }
}

app.post('/mcp/register-webhook', async (req, res) => {
  const { callbackUrl, eventType } = req.body;

  // Log only the sanitized URL — query parameters (including auth codes) are stripped
  logger.info('Webhook registered', { callbackUrl: redactUrl(callbackUrl), eventType });
  // Log line: { callbackUrl: 'https://app.example.com/auth/callback[query redacted]', eventType: 'push' }

  await registerWebhook(callbackUrl, eventType);
  res.json({ status: 'registered' });
});

// SECURE: for tool handler debug logging, redact sensitive arg fields
server.setToolHandler('subscribe_to_events', async (args) => {
  if (process.env.LOG_LEVEL === 'debug') {
    // Explicit redaction list — never log raw URL args that may contain credentials
    logger.debug('Tool args', {
      args: {
        ...args,
        callbackUrl: args.callbackUrl ? redactUrl(args.callbackUrl) : undefined,
        redirectUri: args.redirectUri ? redactUrl(args.redirectUri) : undefined,
        webhookUrl: args.webhookUrl ? redactUrl(args.webhookUrl) : undefined,
      }
    });
  }

  return await subscribeToEvents(args.callbackUrl, args.eventTypes);
});

// DETECTION (SkillAudit):
// logger.*(args.callbackUrl | args.webhookUrl | args.redirectUri) without redactUrl()
// OR logger.info('...', { args }) when args schema includes callbackUrl / redirectUri
const callbackInLog = searchAST(
  'logger.*(args.callbackUrl | args.webhookUrl | args.redirectUri) ' +
  'AND NOT redactUrl() | url.pathname | [query redacted]'
);
if (callbackInLog.length > 0) → HIGH  // SA-SKL-NET-004

Authorization codes are race-condition credentials: the window between the authorization server issuing the code and the log pipeline indexing it is typically under 30 seconds — well within the 60–300 second validity window most providers allow. A log aggregation system that indexes in near-real-time makes this window exploitable by any service account with log read access. Redacting query parameters from callback URLs before logging is a one-line fix that eliminates the entire class of exposure. Apply the redaction at the structured logging call site, not as a post-hoc log scrubber: scrubbers miss log lines before they are indexed.

HIGH
SA-SKL-NET-004 — Callback URL logged verbatim: logger.info('...', { callbackUrl }) exposes OAuth authorization codes and state parameters to log aggregation systems; codes are valid at ingest time. Fix: apply redactUrl() to strip query parameters before logging. Detection: logger.*(args.callbackUrl) without redaction → HIGH.

Remediation summary

FindingSeverityFix
NET-001: Unvalidated webhook URL (SSRF)CRITICALValidate callbackUrl against an HTTPS-only hostname allowlist; resolve DNS and reject RFC1918, loopback, and link-local addresses (DNS rebinding protection); never forward attacker-supplied URLs without both checks
NET-002: Webhook signature not verifiedHIGHUse stripe.webhooks.constructEvent(rawBody, sig, secret) or equivalent HMAC-SHA256 verification; route must receive raw Buffer, not parsed JSON; use crypto.timingSafeEqual() for custom HMAC comparison
NET-003: Webhook without expiry or cleanupHIGHStore webhook IDs in session state; register cleanup tool to deregister all session webhooks; use session-scoped webhook URLs; set expires_at where the external service supports it
NET-004: Callback URL in logsHIGHApply redactUrl() to strip query parameters before passing callback URLs to any logger call; extend redaction to all URL-type tool args in debug log statements

The SSRF pattern in SA-SKL-NET-001 shares a root cause with the injection family: both arise from forwarding attacker-controlled strings to downstream systems without validation. See SA-SKL-STR-008 (SSRF via unsanitized URL construction) for the related string-manipulation path to SSRF in skills that build URLs from tool arguments rather than accepting them wholesale. For the full taxonomy of webhook, network, authentication, and injection patterns across all 15 SA-SKL families, see the complete SA-SKL attack surface reference.

Scan your Claude skill for webhook vulnerabilities

SkillAudit detects unvalidated callback URLs, missing HMAC signature checks, zombie webhook accumulation, and credential-bearing URL logging across the full SA-SKL-NET family in 60 seconds.

Get your free audit