
Secure Your Webhooks Step by Step

Webhooks are one of the most common integration patterns today. Payments, deployments, notifications, analytics. Almost everything relies on them.
They are also one of the most misunderstood and insecure parts of many applications.
A webhook endpoint is usually a public URL. If it is not secured properly, anyone can send requests to it. This article explains how to secure webhooks step by step, using practical decisions you can apply immediately.
What Is a Webhook (Quick Recap)
A webhook is an HTTP endpoint that receives events from another service.
Examples:
- Stripe sends a
payment.successevent - GitHub sends a
pushevent - A CI system sends a
deployment.completedevent
Most of the time, it is a simple POST request with JSON data.
Because it feels simple, webhooks are often treated as just another API endpoint. That assumption is what causes security issues later.
The Most Common and Dangerous Mistake
Many webhook handlers look like this:
1app.post('/webhook', (req, res) => {2 processEvent(req.body)3 res.sendStatus(200)4})
This code assumes that if a request reached the endpoint, it must be legitimate.
What is missing?
- No authentication
- No source verification
- No replay protection
- No payload validation
Anyone who discovers this endpoint can:
- Trigger fake payments
- Mark orders as completed
- Spam internal workflows
This needs to be fixed properly.
Step 1: Always Verify the Sender
The first rule of webhook security is simple.
Never trust the payload alone.
Most providers sign webhook requests using a shared secret. Your server must verify that signature before doing anything else.
The provider:
- Signs the raw request body using a secret
- Sends the signature in a header
Your server:
- Recomputes the signature
- Compares it with the received value
- Rejects the request if they do not match
This confirms that the request came from the expected provider and was not modified in transit.
Example: Signature Verification (Node.js + Express)
1import crypto from 'crypto'2import express from 'express'34const app = express()56app.post(7 '/webhook',8 express.raw({ type: 'application/json' }),9 (req, res) => {10 const signature = req.headers['x-signature']11 const secret = process.env.WEBHOOK_SECRET1213 if (!signature || !secret) {14 return res.sendStatus(401)15 }1617 const expected = crypto18 .createHmac('sha256', secret)19 .update(req.body)20 .digest('hex')2122 const isValid = crypto.timingSafeEqual(23 Buffer.from(signature),24 Buffer.from(expected)25 )2627 if (!isValid) {28 return res.sendStatus(401)29 }3031 res.sendStatus(200)32 }33)
Important details:
- Always verify using the raw request body
- Use timing safe comparison
- Stop processing immediately if verification fails
Step 2: Protect Against Replay Attacks
A valid webhook request can still be dangerous if it is replayed.
If someone captures a real request and sends it again, your system may process the same event multiple times.
To prevent this, webhook requests should include a timestamp.
You should:
- Reject requests older than five minutes
- Reject requests from the future
- Combine timestamp checks with signature verification
Example Logic
1const now = Date.now()2const requestTime = event.timestamp * 100034if (Math.abs(now - requestTime) > 5 * 60 * 1000) {5 throw new Error('Expired webhook')6}
This single check eliminates an entire class of replay attacks.
Step 3: Validate the Payload Strictly
Signature verification confirms who sent the request. It does not guarantee the payload is valid.
Webhook payloads can:
- Change format over time
- Miss required fields
- Contain unexpected values
Always treat webhook data as untrusted input.
Example
1if (!event.type || !event.data || !event.data.id) {2 throw new Error('Invalid webhook payload')3}
In production systems, schema validation libraries are strongly recommended to avoid silent failures.
Step 4: Make Webhook Processing Idempotent
Webhook providers retry requests when they do not receive a success response. This is expected behavior.
Without idempotency, retries can:
- Duplicate database records
- Trigger multiple notifications
- Process the same payment more than once
The fix is to ensure each event is processed only once.
Example
1const exists = await db.webhookEvents.findOne({2 eventId: event.id3})45if (exists) {6 return res.sendStatus(200)7}89await db.webhookEvents.insert({10 eventId: event.id,11 processedAt: new Date()12})
Once this is in place, retries stop being a problem.
Step 5: Respond Fast and Process Later
Webhook providers expect a quick response.
If your endpoint performs heavy logic, it increases the chance of timeouts and retries.
A safer pattern is:
- Verify and validate
- Respond with 200 OK
- Process the event asynchronously
Example
1res.sendStatus(200)2queue.push({ event })
This keeps webhook integrations stable under load.
Step 6: Log Failures Carefully
Logging webhook failures is important, but restraint matters.
Good things to log:
- Event IDs
- Signature verification failures
- Unexpected event types
Avoid logging:
- Secrets
- Full payloads
- Complete request headers
Logs should help debugging without creating new security risks.
A Real World Secure Flow
Payment webhook example:
- Receive webhook
- Verify signature
- Validate timestamp
- Validate schema
- Check idempotency
- Acknowledge request
- Process payment update asynchronously
Each step is simple. Together, they make the system reliable.
Final Takeaway
Webhooks are not just integrations.
They are public entry points into your system.
If you verify signatures, prevent replay attacks, validate payloads, and handle retries safely, your webhook setup becomes boring, predictable, and secure.
In production systems, boring is exactly what you want.
