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.
- Verify an HMAC signature over the raw body, before parsing.
- Enforce a timestamp window, with the timestamp inside the signed payload.
- Compare in constant time.
- Be idempotent — providers retry, and duplicates must not double-charge.
- Respond fast, then process asynchronously. A slow endpoint gets retried, amplifying load.
- 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
// 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.
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.
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.
// 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