Recuro.

5 Security Measures Every Webhook Endpoint Needs

·
Updated March 22, 2026
· Recuro Team
webhookssecurity

Quick Summary — TL;DR

  • Always verify webhook signatures using HMAC-SHA256 with constant-time comparison to prevent spoofed requests.
  • Protect against replay attacks by checking timestamps and make handlers idempotent to handle duplicate deliveries.
  • Return 200 quickly and process webhook payloads asynchronously to avoid provider timeouts and retries.
  • Layer your defenses: signature verification, payload validation, HTTPS, IP allowlisting, rate limiting, and logging.
  • Rotate signing secrets periodically and validate URLs in payloads to prevent SSRF attacks.
Secure Webhook Endpoints

Your inbound webhook endpoint is a public URL that accepts POST requests and acts on the data it receives. Without proper security, anyone who discovers that URL can send fake events — triggering actions, corrupting data, or worse.

Here’s how to lock it down.

Webhook security threats at a glance

Quick reference of threats and mitigations:

ThreatRiskSolution
Spoofed requestsAttacker sends fake eventsVerify signatures
Tampered payloadsData modified in transitHMAC validation
Malformed dataBad input causes errors or injectionValidate payloads
Replay attacksOld requests re-sentTimestamp checks
Duplicate deliverySame event processed twiceIdempotency keys
Provider timeoutsRetries flood your endpointAsync processing
Unauthorized accessUnknown IPs hit your endpointIP allowlisting + HTTPS
Compromised secretsLeaked signing keySecret rotation
Data exposureSensitive data in payloadsMinimize payload data
Undetected breachesAttacks go unnoticedLogging and auditing
Stale integrationsForgotten endpoints stay activeSubscription expiration
SSRFAttacker tricks your server into calling internal servicesURL validation
Volumetric abuseEndpoint overwhelmed by requestsRate limiting

Why webhook security matters

When you register a webhook with a service like Stripe, GitHub, or Shopify, you’re telling that service: “Send webhook events to this URL.” The service sends a POST request with a JSON payload whenever something happens — a payment succeeds, a pull request is merged, an order is placed.

The problem: HTTP POST requests don’t inherently prove who sent them. Without verification, your endpoint can’t distinguish between a legitimate event from Stripe and a forged request from an attacker. If your endpoint processes payments, updates user accounts, or modifies data, this is a serious vulnerability.

1. Verify webhook signatures

Most webhook providers sign each request with a shared secret using HMAC-SHA256. The webhook signature proves the request came from the expected sender and hasn’t been tampered with. Without this step, every other measure is secondary.

How it works

  1. When you register your webhook, the provider gives you a signing secret
  2. For each webhook, the provider computes HMAC-SHA256(secret, request_body) and includes the result in a header
  3. Your server computes the same hash and compares it to the header value
  4. If they match, the request is authentic

Implementation

Node.js:

import crypto from 'crypto';
function verifySignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}

PHP:

function verifySignature(string $payload, string $signature, string $secret): bool
{
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}

Python:

import hmac
import hashlib
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)

Use the raw body

The signature is computed over the exact bytes of the request body. If your framework parses the JSON before you access it and you re-serialize for verification, the bytes may differ (different key ordering, whitespace). Always capture the raw body before parsing.

Use constant-time comparison

Never compare signatures with == or ===. Standard string comparison returns early on the first mismatched character, leaking timing information. Use crypto.timingSafeEqual() (Node.js), hash_equals() (PHP), or hmac.compare_digest() (Python).

You can test your signature verification logic with our HMAC signature verifier tool — it has built-in presets for Stripe, GitHub, and Shopify. For a deeper dive into how signatures work, see our webhook signature glossary entry.

2. Validate the payload

A valid signature proves the request is authentic, but you should still validate the data:

  • Check required fields — reject payloads missing expected fields rather than failing mid-processing
  • Validate event types — only process event types you expect. Ignore unknown types gracefully (return 200, do nothing)
  • Check resource IDs — if the event references a user, order, or subscription, verify it exists in your database before acting on it
  • Sanitize inputs — never trust webhook data in SQL queries, HTML templates, or shell commands
app.post('/webhooks/stripe', (req, res) => {
const event = JSON.parse(req.rawBody);
// Only handle events we care about
const handled = ['payment_intent.succeeded', 'customer.subscription.deleted'];
if (!handled.includes(event.type)) {
return res.status(200).json({ received: true });
}
// Validate the referenced resource exists
const customer = await db.customers.findByStripeId(event.data.object.customer);
if (!customer) {
console.warn(`Unknown customer: ${event.data.object.customer}`);
return res.status(200).json({ received: true });
}
// Process the event...
});

3. Protect against replay attacks

An attacker could intercept a legitimate signed webhook and re-send it later. To prevent this, many providers include a timestamp in the signature:

  • Stripe includes a t parameter in the Stripe-Signature header
  • Slack sends a timestamp in X-Slack-Request-Timestamp
  • Shopify doesn’t include a timestamp — use idempotency keys instead

Reject requests with old timestamps:

const timestamp = parseInt(req.headers['x-webhook-timestamp']);
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 300;
if (timestamp < fiveMinutesAgo) {
return res.status(401).json({ error: 'Request too old' });
}

Important: include the timestamp in the HMAC computation. If the timestamp is sent as a separate, unsigned header, an attacker can replace it with a fresh value while replaying the old body. Stripe and Slack both include the timestamp in the signed payload — verify your provider does the same.

4. Make handlers idempotent

Webhooks are delivered with at-least-once delivery semantics, not exactly once. Network issues, timeouts, and retries mean your endpoint may receive the same event multiple times. If your handler isn’t idempotent, you’ll process duplicates — double-charging customers, sending duplicate emails, or creating duplicate records.

The fix: Use the event’s unique ID as an idempotency key.

app.post('/webhooks/stripe', async (req, res) => {
const event = JSON.parse(req.rawBody);
// Check if we've already processed this event
const existing = await db.processedEvents.findByEventId(event.id);
if (existing) {
return res.status(200).json({ received: true });
}
// Process the event
await handleEvent(event);
// Record that we've processed it
await db.processedEvents.create({ eventId: event.id });
res.status(200).json({ received: true });
});

5. Return 200 quickly

Webhook providers expect a fast response. If your endpoint takes too long, the provider will assume delivery failed and retry — potentially causing duplicates. Most providers have a timeout of 5-30 seconds.

If processing takes time, queue it:

app.post('/webhooks/stripe', async (req, res) => {
// Verify signature first
if (!verifySignature(req.rawBody, req.headers['stripe-signature'], secret)) {
return res.status(401).end();
}
// Queue for async processing — don't process inline
await queue.add('process-stripe-webhook', {
eventId: event.id,
payload: req.rawBody,
});
// Return 200 immediately
res.status(200).json({ received: true });
});

This way your endpoint responds in milliseconds. The actual work happens in a background job with proper retry logic.

6. Restrict access

IP allowlisting

Some providers publish their IP ranges. Where available, restrict your webhook endpoint to only accept requests from those IPs:

  • Stripe publishes IPs in their documentation
  • GitHub exposes them via their meta API
  • Shopify publishes webhook IPs per region

HTTPS only

Always use HTTPS for webhook URLs. Webhook payloads contain sensitive data — customer emails, payment amounts, account identifiers. HTTPS encrypts the payload in transit and prevents man-in-the-middle attacks.

Mutual TLS (mTLS)

For high-security integrations, consider mutual TLS. Standard HTTPS only verifies the server’s identity — the client (webhook provider) is unauthenticated at the transport layer. With mTLS, both sides present certificates:

  1. Your server presents its certificate to the provider (standard TLS)
  2. The provider presents a client certificate to your server
  3. Your server validates the client certificate against a trusted CA

This gives you cryptographic proof of the sender’s identity at the network level, before your application code even runs. It’s overkill for most webhooks, but valuable for financial services, healthcare, and other regulated industries where HMAC alone may not satisfy compliance requirements.

Certificate pinning

If your webhook provider uses a known TLS certificate, you can pin it — meaning your server only accepts connections presenting that specific certificate or public key. This prevents attacks where a compromised CA issues a fraudulent certificate for the provider’s domain.

Certificate pinning is brittle (you must update pins when the provider rotates certificates), so use it only when the threat model justifies it.

Separate endpoint paths

Don’t use predictable URLs like /webhooks or /api/webhook. Use a path that includes a random token:

https://yourapp.com/webhooks/stripe/a8f3k9x2m4

This isn’t a replacement for signature verification, but it adds an extra layer — an attacker needs to discover the URL before they can even attempt to send fake events.

7. Rotate webhook secrets

Signing secrets can leak — through logs, config dumps, or team members who leave. Rotate them periodically:

  1. Generate a new secret in the provider’s dashboard
  2. Update your server to accept both the old and new secret during a transition window
  3. Verify the new secret works by checking incoming webhooks against both secrets
  4. Remove the old secret after the transition period (24-48 hours is typical)
function verifySignatureWithRotation(payload, signature, secrets) {
return secrets.some(secret => {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
});
}
// During rotation, check both secrets
const isValid = verifySignatureWithRotation(
req.rawBody,
req.headers['x-webhook-signature'],
[process.env.WEBHOOK_SECRET_NEW, process.env.WEBHOOK_SECRET_OLD]
);

Most providers (Stripe, GitHub, Twilio) support rolling secrets — they’ll sign with the new secret while continuing to include the old signature during a grace period. Check your provider’s documentation.

If you’re building a webhook provider

Sections 1-7 above focus on receiving webhooks securely. The next two sections — minimizing payload data and expiring subscriptions — apply when you are the one sending webhooks. If you only consume webhooks, you can skip to section 11 (SSRF prevention), which applies to all webhook receivers.

8. Never send sensitive data in payloads

If you’re building a webhook provider (sending webhooks to your users’ endpoints), minimize the data in payloads. Instead of sending full records, send a reference:

// Bad: full customer record in the payload
{
"event": "customer.updated",
"data": {
"id": "cus_123",
"email": "[email protected]",
"ssn": "123-45-6789",
"credit_card": "4242..."
}
}
// Good: reference only — recipient fetches details via authenticated API
{
"event": "customer.updated",
"data": {
"id": "cus_123"
}
}

This way, even if the webhook is intercepted or the receiving endpoint is compromised, the attacker gets only an opaque ID — not sensitive data. The recipient calls your API (with proper authentication) to fetch the full record.

If you’re consuming webhooks, be aware that payloads may contain PII. Don’t log full payloads to general-purpose logging systems. Redact or exclude sensitive fields before storing.

9. Log and audit every webhook event

Every webhook your endpoint receives should be logged — whether it passes verification or not. This gives you:

  • Forensic evidence when something goes wrong
  • Attack detection — a spike in failed signature checks may indicate someone probing your endpoint
  • Debugging data — correlate webhook events with downstream actions
  • Compliance records — many regulations require audit trails for data processing events

What to log:

function logWebhookEvent(req, verificationResult, processingResult) {
logger.info('webhook_received', {
timestamp: new Date().toISOString(),
source_ip: req.ip,
event_type: req.body?.type || 'unknown',
event_id: req.body?.id || 'unknown',
signature_valid: verificationResult,
processing_status: processingResult,
// Don't log the full payload — it may contain PII
payload_size: req.headers['content-length'],
user_agent: req.headers['user-agent'],
});
}

Don’t log: full request bodies (PII risk), signing secrets, or raw signature headers (timing oracle if logs are exposed).

Set up alerts on anomalies: sudden drops in webhook volume (provider issues), spikes in failed verifications (attack attempts), or unexpected event types.

10. Set expiration dates on webhook subscriptions

If you’re a webhook provider, build expiration into your subscription model. Webhook URLs that were valid a year ago may now point to decommissioned servers, acquired domains, or endpoints with different ownership.

  • Require periodic re-registration — subscriptions expire after 30, 60, or 90 days unless renewed
  • Send expiration warnings — notify subscribers before their webhook is deactivated
  • Verify endpoints on registration — send a challenge request (like a verification token) and require the endpoint to echo it back before activating the subscription

If you’re consuming webhooks, audit your active registrations periodically. Remove subscriptions you no longer need — every active webhook is an attack surface.

Back to receiver-side security

The remaining sections apply to everyone consuming webhooks.

11. Prevent SSRF attacks

Server-Side Request Forgery (SSRF) is a critical risk when your webhook handler makes outbound requests based on webhook data. An attacker could send a webhook with a URL pointing to your internal network:

{
"event": "file.ready",
"download_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}

If your handler fetches download_url without validation, the attacker can read your cloud metadata, access internal services, or scan your private network.

Defenses:

  • Validate URLs before fetching — reject private IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x), link-local addresses, and non-HTTP(S) schemes
  • Resolve DNS before connecting — check the resolved IP, not just the hostname (an attacker can point evil.com at 169.254.169.254)
  • Use an allowlist — if you know which domains the webhook data should reference, only allow those
import { isIP } from 'net';
import dns from 'dns/promises';
const PRIVATE_RANGES = [
/^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./,
/^169\.254\./, /^0\./, /^fc00:/i, /^fe80:/i, /^::1$/,
];
async function isSafeUrl(url) {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const addresses = await dns.resolve4(parsed.hostname);
return !addresses.some(ip =>
PRIVATE_RANGES.some(range => range.test(ip))
);
}

SSRF is underrated as a webhook attack vector. Cloud metadata endpoints at 169.254.169.254 are reachable from inside most cloud providers — and an attacker who can get your server to fetch that URL has access to IAM credentials.

12. Rate-limit your webhook endpoint

Even with signature verification, your endpoint can be overwhelmed by volume. An attacker can flood it with requests (valid or not), causing resource exhaustion or increased infrastructure costs.

Apply rate limiting at multiple layers:

  • Network layer — use your CDN or load balancer (Cloudflare, AWS ALB) to limit requests per IP
  • Application layer — limit requests per source or event type
import rateLimit from 'express-rate-limit';
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 200, // 200 requests per minute per IP
message: { error: 'Too many requests' },
standardHeaders: true,
});
app.post('/webhooks/stripe', webhookLimiter, (req, res) => {
// ... handler
});

Set limits generously enough to handle legitimate burst traffic (e.g., a flash sale sending hundreds of payment events) but tight enough to block abuse. Monitor your normal webhook volume to establish a baseline.

13. Webhook security by provider

Different providers implement signing differently. Here’s a quick reference for the most common ones:

ProviderSignature headerAlgorithmIncludes timestampVerification docs
StripeStripe-SignatureHMAC-SHA256Yes (t= prefix)Signs timestamp.payload
GitHubX-Hub-Signature-256HMAC-SHA256NoSigns raw body
ShopifyX-Shopify-Hmac-SHA256HMAC-SHA256 (Base64)NoSigns raw body, Base64-encoded
TwilioX-Twilio-SignatureHMAC-SHA1 (Base64)NoSigns URL + sorted POST params
SlackX-Slack-SignatureHMAC-SHA256Yes (X-Slack-Request-Timestamp)Signs v0:timestamp:body
SendGridX-Twilio-Email-Event-Webhook-SignatureECDSAYes (X-Twilio-Email-Event-Webhook-Timestamp)Signs timestamp + payload
PayPalCertificate-basedSHA256withRSAYesVerify against PayPal cert

Key differences to watch for:

  • Stripe and Slack include the timestamp in the signed string — always verify the timestamp before checking the signature
  • Shopify Base64-encodes the signature — decode it before comparing
  • Twilio (voice/SMS) uses SHA1, not SHA256 — and signs the full request URL plus sorted POST parameters, not just the body
  • SendGrid uses ECDSA (asymmetric cryptography), not HMAC — you verify with their public key, not a shared secret
  • PayPal uses certificate-based verification — download their cert and verify the signature against it

Always read your specific provider’s documentation. The signing scheme matters — getting one detail wrong (Base64 vs hex, SHA1 vs SHA256, body vs URL+body) means verification will always fail.

14. Validate Content-Type before parsing

A subtle attack vector: sending webhook-like requests with unexpected Content-Types. If your handler blindly parses the body as JSON regardless of the Content-Type header, it may behave differently than expected — or worse, trigger parser vulnerabilities.

app.post('/webhooks/stripe', (req, res) => {
// Reject anything that isn't JSON
if (req.headers['content-type'] !== 'application/json') {
return res.status(415).json({ error: 'Unsupported Media Type' });
}
// Now safe to parse
const event = JSON.parse(req.rawBody);
// ...
});
if (request()->header('Content-Type') !== 'application/json') {
abort(415, 'Unsupported Media Type');
}

This is especially important if your framework supports multiple body parsers (JSON, XML, form-urlencoded). An attacker sending XML to a JSON endpoint could trigger XXE (XML External Entity) attacks if your XML parser isn’t hardened.

Security checklist

CheckStatus
Verify HMAC signatures on every requestRequired
Use constant-time comparison for signaturesRequired
Read the raw request body (not re-serialized JSON)Required
Validate event types and required fieldsRequired
Make handlers idempotent (deduplicate by event ID)Required
Use HTTPS for all webhook URLsRequired
Return 200 quickly, process async if neededRecommended
Reject requests with old timestamps (replay protection)Recommended
Log all webhook events (successes and failures)Recommended
Rotate signing secrets periodicallyRecommended
Validate URLs in payloads to prevent SSRFRecommended
Rate-limit your webhook endpointRecommended
Restrict by IP where provider publishes rangesOptional
Use unpredictable endpoint pathsOptional
Use mutual TLS for regulated industriesOptional
Pin provider certificatesOptional
Set expiration dates on webhook subscriptionsOptional
Avoid sending sensitive data in webhook payloadsOptional

Testing your webhook security

Before going to production:

  1. Verify your signature logic works — use the HMAC signature verifier to generate test signatures and confirm your code validates them correctly
  2. Test with real webhooks — use the provider’s test mode to send actual webhook events to your endpoint
  3. Test with fake requests — send a request with an invalid signature and confirm your endpoint rejects it with 401
  4. Test replay protection — send a request with a valid signature but an old timestamp and confirm rejection
  5. Test idempotency — send the same event twice and verify it’s only processed once
  6. Test rate limiting — send a burst of requests and confirm your rate limiter kicks in
  7. Test SSRF defenses — send a webhook containing a URL pointing to http://127.0.0.1 and verify your handler refuses to fetch it
  8. Test with the webhook tester — send arbitrary HTTP requests to your endpoint and inspect the response

Frequently asked questions

What is webhook security?

Webhook security refers to the practices and mechanisms used to protect webhook endpoints from unauthorized access, data tampering, and abuse. Since webhook endpoints are publicly accessible URLs that accept HTTP POST requests, they need protection against spoofed requests, replay attacks, data interception, and denial-of-service attacks. Key measures include HMAC signature verification, HTTPS encryption, IP allowlisting, payload validation, and rate limiting.

How do I verify a webhook signature?

To verify a webhook signature, compute an HMAC-SHA256 hash of the raw request body using the shared signing secret provided by the webhook sender. Then compare your computed hash with the signature included in the request header using a constant-time comparison function (like crypto.timingSafeEqual in Node.js, hash_equals in PHP, or hmac.compare_digest in Python). If the values match, the request is authentic and has not been tampered with. Always use the raw body bytes, not re-serialized JSON.

What is HMAC-SHA256 and why is it used for webhooks?

HMAC-SHA256 (Hash-based Message Authentication Code using SHA-256) is a cryptographic algorithm that combines a secret key with a message to produce a unique hash. Webhook providers use it because it solves two problems at once: it proves the request was sent by someone who knows the shared secret (authentication), and it proves the request body has not been modified in transit (integrity). It is fast, widely supported across programming languages, and considered cryptographically secure.

Can webhooks be spoofed?

Yes. Without signature verification, any HTTP client can send a POST request to your webhook endpoint with a forged payload. An attacker who discovers your webhook URL can send fake events that trigger actions in your system — such as marking payments as completed, creating fake accounts, or modifying data. HMAC signature verification is the primary defense: without the shared signing secret, an attacker cannot produce a valid signature.

How do I prevent replay attacks on webhooks?

To prevent replay attacks, check the timestamp included in the webhook request (most providers include one in the signature header). Reject any request where the timestamp is older than a short window, typically 5 minutes. Make sure the timestamp is part of the signed payload so an attacker cannot replace it. For providers that do not include timestamps, use the event's unique ID as an idempotency key to ensure each event is processed only once.

Should I use IP allowlisting for webhooks?

IP allowlisting adds a useful layer of defense but should not be your only security measure. Many webhook providers (Stripe, GitHub, Shopify) publish their IP ranges, and restricting your endpoint to those IPs blocks unauthorized sources at the network level. However, IP ranges can change, and spoofing source IPs is possible in some network configurations. Always combine IP allowlisting with HMAC signature verification for defense in depth.


Stop managing infrastructure. Start scheduling jobs.

Recuro handles cron scheduling, retries, alerts, and execution logs — so you can focus on building your product.

No credit card required