Webhooks: Sending and Receiving Safely

Two problems in one feature — proving an inbound delivery is genuine, and not turning an outbound one into SSRF.

Webhooks are two distinct security problems that share a name. Receiving one means accepting an unauthenticated POST from the internet. Sending one means making an HTTP request to an address a customer chose.

Receiving: prove it is genuine

Your webhook endpoint is a public URL that performs privileged actions — marking an invoice paid, provisioning an account. Anything can POST to it.

  1. Verify an HMAC signature over the raw body, before parsing.
  2. Enforce a timestamp window, with the timestamp inside the signed payload.
  3. Compare in constant time.
  4. Be idempotent — providers retry, and duplicates must not double-charge.
  5. Respond fast, then process asynchronously. A slow endpoint gets retried, amplifying load.
  6. Do not trust the payload's contents. A "payment succeeded" event should be confirmed against the provider's API before you ship anything.

Sending: it is SSRF by design

The destination is customer-supplied, so every rule from the SSRF lesson applies: validate the resolved address, connect to it, no redirects, bound the response, no credentials attached.

Additionally: sign what you send so the receiver can verify it, use exponential backoff with jitter so a customer outage does not become your outage, and cap total attempts.

The mistake that matters most

Verifying the signature against a re-serialised body. JSON key order and whitespace change when you parse and stringify, so every signature fails. Capture the raw bytes and verify those.

Example

Example · javascript
// The one detail that breaks every first implementation

// ❌ express.json() has already consumed and re-parsed the body
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
  verify(JSON.stringify(req.body), req.get('stripe-signature'));  // never matches
});

// ✅ Capture the RAW bytes for this route only
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    verify(req.body, req.get('stripe-signature'));   // req.body is a Buffer
    const event = JSON.parse(req.body.toString('utf8'));   // parse AFTER
  });

When to use it

  • A forged payment-succeeded webhook is rejected because the endpoint verifies an HMAC signature rather than trusting the payload.
  • A retried delivery does not double-ship an order because the handler is idempotent on the provider's event id.
  • A customer's webhook endpoint pointed at an internal address is refused by address validation in the delivery worker.

More examples

Receiving: verification, idempotency and async processing

Returning 200 for an already-processed event rather than an error is deliberate: providers treat non-2xx as failure and will retry the duplicate forever.

Example · javascript
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';

const TOLERANCE_S = 300;
const SECRETS = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD]
  .filter(Boolean);                      // accept both during rotation

app.post('/webhooks/payments',
  express.raw({ type: 'application/json', limit: '256kb' }),
  async (req, res) => {
    // ── 1. Verify BEFORE parsing ──────────────────────────────────────
    const ts = Number(req.get('x-signature-timestamp'));
    const sig = (req.get('x-signature') ?? '').replace(/^v1=/, '');

    if (!ts || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_S) {
      return res.status(400).json({ error: 'stale_timestamp' });
    }

    const payload = `v1:${ts}:${req.body.toString('utf8')}`;
    const given = Buffer.from(sig, 'hex');

    const valid = SECRETS.some((secret) => {
      const expected = createHmac('sha256', secret).update(payload).digest();
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
    if (!valid) {
      logger.warn({ ip: req.ip }, 'webhook signature verification failed');
      return res.status(401).json({ error: 'bad_signature' });
    }

    // ── 2. Parse only now ─────────────────────────────────────────────
    let event;
    try {
      event = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.status(400).json({ error: 'invalid_json' });
    }
    const parsed = eventSchema.safeParse(event);      // validate the shape too
    if (!parsed.success) return res.status(400).json({ error: 'invalid_event' });

    // ── 3. Idempotency: providers retry, and duplicates must be free ──
    const inserted = await db('webhook_events')
      .insert({ id: parsed.data.id, type: parsed.data.type, received_at: new Date() })
      .onConflict('id').ignore()
      .returning('id');

    if (!inserted.length) {
      return res.status(200).json({ status: 'already_processed' });   // 200, not 409
    }

    // ── 4. Acknowledge FAST, process asynchronously ───────────────────
    // A slow handler gets retried, which multiplies the load during an incident.
    await queue.add('process-webhook', { eventId: parsed.data.id }, {
      attempts: 5, backoff: { type: 'exponential', delay: 1000 },
    });

    res.status(200).json({ status: 'accepted' });
  });

// ── 5. And do not trust the contents for anything financial ──────────
worker.process('process-webhook', async (job) => {
  const event = await db('webhook_events').where({ id: job.data.eventId }).first();

  // The webhook says the payment succeeded. Ask the provider.
  const authoritative = await paymentProvider.retrievePayment(event.payload.paymentId);
  if (authoritative.status !== 'succeeded') {
    logger.error({ event }, 'webhook claimed success, provider disagrees');
    return;
  }
  await fulfilOrder(authoritative);
});

Sending: SSRF-safe delivery with backoff

Re-validating the destination on every delivery rather than at registration is essential — a customer's DNS record can be changed to point inward at any time.

Example · javascript
import { createHmac, randomUUID } from 'crypto';

// The destination is customer-supplied, so this IS an SSRF sink.
export async function deliverWebhook(subscription, event) {
  // 1. Validate the destination every time — DNS changes between deliveries.
  const { host, address, port } = await resolveAndValidate(subscription.url);

  const body = JSON.stringify({
    id: event.id,
    type: event.type,
    createdAt: event.createdAt,
    data: event.data,
  });

  // 2. Sign it, with the timestamp inside the signed payload.
  const ts = Math.floor(Date.now() / 1000);
  const signature = createHmac('sha256', subscription.secret)
    .update(`v1:${ts}:${body}`).digest('hex');

  // 3. Deliver to the VALIDATED address, with every bound applied.
  const res = await httpsRequestToAddress({
    address, host, port,
    method: 'POST',
    path: new URL(subscription.url).pathname,
    headers: {
      'Content-Type': 'application/json',
      'X-Signature-Timestamp': String(ts),
      'X-Signature': `v1=${signature}`,
      'X-Event-Id': event.id,
      'X-Delivery-Id': randomUUID(),
      'User-Agent': 'abc-webhooks/1.0',
      // No cookies, no Authorization, no internal headers.
    },
    body,
    timeoutMs: 10_000,
    maxResponseBytes: 64 * 1024,     // we do not care what they say
    followRedirects: false,          // a 302 to an internal host is the bypass
  });

  return { status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300 };
}

// 4. Retry with exponential backoff AND jitter — a customer outage must not
//    turn into a synchronised retry storm from your side.
export async function scheduleDelivery(subscriptionId, eventId, attempt = 0) {
  const MAX_ATTEMPTS = 8;
  if (attempt >= MAX_ATTEMPTS) {
    await db('webhook_subscriptions').where({ id: subscriptionId })
      .update({ disabled_at: new Date(), disabled_reason: 'max_attempts' });
    await notifyCustomer(subscriptionId, 'Your webhook endpoint has been disabled.');
    return;
  }

  const base = Math.min(2 ** attempt * 1000, 3600_000);      // cap at 1 hour
  const jitter = Math.floor(Math.random() * base * 0.3);
  await queue.add('deliver', { subscriptionId, eventId, attempt },
    { delay: base + jitter });
}

// 5. Circuit-break a consistently failing endpoint so one broken customer does
//    not consume your delivery workers.
if (await failureRate(subscriptionId, '1h') > 0.9) {
  await pauseSubscription(subscriptionId, { minutes: 30 });
}

The endpoint registration form, done properly

Blocking your own domains at registration is easy to forget and directly exploitable: without it, your delivery workers become a proxy into your own network.

Example · javascript
// Registration is where you can refuse the dangerous destinations cheaply,
// and where you prove the customer actually controls the endpoint.
app.post('/api/webhooks', auth, validate({ body: webhookSchema }), async (req, res) => {
  // 1. https only, no credentials, and a public resolved address.
  const { url } = await resolveAndValidate(req.body.url);

  // 2. Refuse OUR OWN domains — otherwise a customer can aim your delivery
  //    workers at your own API, with your own network position.
  if (OUR_DOMAINS.some((d) => url.hostname === d || url.hostname.endsWith(`.${d}`))) {
    return res.status(400).json({ error: 'cannot_target_our_own_domain' });
  }

  // 3. Generate the signing secret. Show it once.
  const secret = crypto.randomBytes(32).toString('base64url');

  const subscription = await db('webhook_subscriptions').insert({
    tenant_id: req.user.tenant,
    url: url.toString(),
    secret_hash: sha256(secret),          // store a hash, like an API key
    events: req.body.events,              // an allowlist of event types
    created_by: req.user.id,
    verified_at: null,
  }).returning('*');

  // 4. Prove they control the endpoint before sending real data to it.
  const challenge = crypto.randomUUID();
  await redis.set(`webhook-challenge:${subscription.id}`, challenge, 'EX', 600);

  const probe = await deliverWebhook(
    { ...subscription, secret },
    { id: 'verify', type: 'endpoint.verification', data: { challenge } },
  ).catch(() => null);

  if (!probe?.ok) {
    return res.status(400).json({
      error: 'endpoint_unreachable',
      detail: 'The endpoint must respond 2xx to a verification delivery.',
    });
  }

  res.status(201).json({
    id: subscription.id,
    url: subscription.url,
    secret,                                // the only time it is visible
    events: subscription.events,
  });
});

// And give customers what they need to verify correctly — publish the exact
// canonical string, byte for byte:
//   signed payload = "v1:" + timestamp + ":" + raw_request_body
//   signature      = hex(hmac_sha256(secret, signed_payload))
//   header         = X-Signature: v1=<signature>
// "Use our SDK" is not a specification, and integrators will build their own.

Discussion

  • Be the first to comment on this lesson.