WebhooksSecurityHMACAPIBackendNode.jsAPI SecurityBackend DevelopmentCybersecurityCryptography

Webhook Security 101: HMAC, Signatures, and How to Not Get Spoofed

Webhook Security 101: HMAC, Signatures, and How to Not Get Spoofed

Webhook Security 101: HMAC, Signatures, and How to Not Get Spoofed

Imagine your payment gateway tells your application:

"A payment of ₹5,000 has been completed."

Should your server believe it immediately?

Absolutely not.

Anyone on the internet can send an HTTP POST request that looks exactly like a legitimate webhook. Without proper verification, an attacker could fake payment confirmations, trigger deployments, create fake orders, or even delete user data.

That's why nearly every major platform—Stripe, GitHub, Discord, Slack, Shopify, Razorpay, and many others—signs webhook requests before sending them.

In this guide, you'll learn:

Let's secure your webhook endpoints.


What Is a Webhook?

A webhook is simply an HTTP callback.

Instead of your application repeatedly asking another service:

"Any updates?" "Any updates now?" "How about now?"

the service pushes data to your endpoint whenever something happens.

Example:

Customer completes payment
        ↓
Payment Provider
        ↓
POST /webhook
        ↓
Your Backend

Example payload:

{
  "event": "payment.completed",
  "payment_id": "pay_12345",
  "amount": 5000,
  "currency": "INR"
}

Seems simple.

The problem?

Anyone can send the exact same JSON to your server.


Why Webhooks Are Dangerous

Suppose your endpoint looks like this:

POST /webhook

Your backend receives:

{
  "event": "payment.completed",
  "amount": 100000
}

If your code simply processes this request:

if (body.event === "payment.completed") {
    markOrderPaid();
}

You've just trusted a complete stranger.

An attacker could use curl:

curl https://yourapi.com/webhook \
-H "Content-Type: application/json" \
-d '{
  "event":"payment.completed",
  "amount":100000
}'

Congratulations.

They just received products without paying.


The Goal of Webhook Verification

Webhook verification answers one question:

Did this request actually come from the service that claims to have sent it?

Notice what we're not asking.

We're not checking:

We're verifying who created the message.


Enter HMAC

HMAC stands for:

Hash-based Message Authentication Code

Although the name sounds intimidating, the concept is surprisingly simple.

Both you and the webhook provider share a secret.

Example:

SuperSecretWebhookKey123

Nobody else knows it.

When the provider sends data, it computes:

HMAC(secret, request_body)

This produces something like:

6f8df64c91d9...

It sends:

POST /webhook

Signature:
6f8df64c91d9...

Your server performs the exact same calculation.

If both signatures match:

✔ Request is authentic.

If they don't:

❌ Reject immediately.


Visualizing the Process

Webhook Provider
─────────────────────────────

Payload
↓

HMAC(secret + payload)

↓

Signature

↓

Send both payload + signature

─────────────────────────────

Your Server

Payload
↓

HMAC(secret + payload)

↓

Compare

↓

Match?
│
├── Yes → Process webhook
│
└── No → Reject

No secret.

No valid signature.

No access.


Why Hashing Alone Isn't Enough

Many developers think this is enough:

SHA256(body)

It isn't.

Anyone can calculate SHA-256.

Example:

SHA256("Hello")

Every computer in the world gets the same result.

An attacker can calculate it too.

HMAC is different.

It includes the secret:

SHA256(secret + message)

Without knowing the secret, generating the correct signature is practically impossible.


What Does the Signature Look Like?

Different providers use different headers.

GitHub:

X-Hub-Signature-256:
sha256=...

Stripe:

Stripe-Signature:
t=171111111,v1=...

Discord:

X-Signature-Ed25519

Slack:

X-Slack-Signature

The principle remains exactly the same.


Basic HMAC Verification (Node.js)

import crypto from "crypto";

const signature = req.headers["x-signature"];

const expected = crypto
  .createHmac("sha256", process.env.WEBHOOK_SECRET)
  .update(rawBody)
  .digest("hex");

if (signature !== expected) {
    return res.status(401).send("Invalid signature");
}

Never verify against the parsed JSON object.

Always use the raw request body exactly as received.


Why the Raw Body Matters

This is one of the most common mistakes.

These two JSON objects are identical:

{
  "a":1,
  "b":2
}
{"b":2,"a":1}

After parsing, they're equivalent.

But their original bytes are different.

HMAC works on bytes.

Changing whitespace, formatting, or key order changes the signature.

Always compute the HMAC using the untouched request body.


Why Timing-Safe Comparison Matters

Many developers compare signatures like this:

if (signature === expected)

This comparison can leak tiny timing differences.

Instead, use:

crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
);

Timing-safe comparison helps prevent timing attacks that attempt to infer valid signatures byte by byte.


Replay Attacks

Imagine an attacker intercepts a legitimate webhook.

They don't know the secret.

But they don't need to.

They simply resend the exact request.

Payment Completed

↓

Captured Request

↓

Replay 500 times

Without replay protection:

The signature is still valid because the payload hasn't changed.


How Timestamps Prevent Replay Attacks

Many providers include a timestamp.

Example:

Timestamp:
1711111111

The signature covers:

timestamp + payload

Your server verifies:

  1. Signature matches
  2. Timestamp is recent

Example:

Current Time:
1711111200

Webhook Time:
1711111111

Difference:
89 seconds

Accept

But:

Difference:
30 minutes

Reject

Stripe recommends accepting requests only within a small time window (commonly five minutes).


Event IDs for Extra Protection

Many webhook providers include an event ID.

Example:

{
  "id": "evt_123456"
}

Store processed IDs.

If you receive:

evt_123456

again:

Already processed

↓

Ignore

This prevents duplicate processing even if a webhook is retried.


Common Mistakes Developers Make

1. Trusting IP Addresses Alone

IP allowlists can add another layer of security, but they shouldn't replace signature verification.

Cloud providers, proxies, and infrastructure changes can make IP-based checks unreliable on their own.


2. Logging Secrets

Avoid this:

console.log(secret);

Or:

console.log(signature, secret);

Secrets belong in secure environment variables, not logs.


3. Ignoring Failed Verification

Never continue processing after a failed signature check.

Bad:

if (!valid)
    console.log("Invalid");

processWebhook();

Good:

if (!valid)
    return res.status(401).end();

processWebhook();

4. Using the Parsed Body

Always verify:

Raw bytes

Not:

Parsed JSON

5. Using Weak Secrets

Don't use:

password123

Generate long, random secrets (at least 32 bytes of entropy) and rotate them periodically.


Best Practices Checklist

Before deploying a webhook endpoint, make sure you:


Helpful Tools for Working with Webhooks

When debugging or implementing webhook verification, these tools can save time:

Hash Generator

Want to understand how hashing works before diving into HMAC? A Hash Generator lets you experiment with algorithms like SHA-256 and compare outputs for different inputs. While HMAC uses a secret key in addition to hashing, seeing how plain hashes change with different data is a great way to build intuition right in your browser:

Hash Generator (MD5 / SHA1 / SHA256)

Hex Encoder/Decoder

HMAC digests (and output signature buffers like sha256=...) are commonly represented as hexadecimal strings (.digest("hex")). A Hex Encoder/Decoder helps you convert raw text or inspect and verify hexadecimal signature representation values during development:

Hex Encoder / Decoder

Base64 Encoder/Decoder

Some providers (or custom webhook implementations) encode binary payload data, asymmetric public/private keys, or signatures using Base64 instead of Hex. A Base64 Encoder/Decoder lets you instantly inspect and convert encoded strings while debugging webhook payloads:

Base64 Encoder / Decoder

Real-World Flow

Here's what a secure webhook request typically looks like:

Webhook Provider

↓

Create JSON payload

↓

Generate timestamp

↓

Compute HMAC(secret + timestamp + payload)

↓

Send:
- Payload
- Timestamp
- Signature

↓

Your Server

↓

Read raw body

↓

Verify timestamp

↓

Recompute HMAC

↓

Timing-safe comparison

↓

Already processed?

↓

No

↓

Process event

Every step exists to make spoofing or replaying requests significantly harder.


Final Thoughts

Webhook security isn't complicated—but it does require discipline.

The biggest mistake developers make is assuming that because a request contains valid-looking JSON, it must be legitimate. In reality, any HTTP client can send a request to your webhook endpoint.

A secure webhook implementation should verify the signature, use the raw request body, compare signatures safely, validate timestamps, and protect against replay attacks. These small checks are what separate a production-ready webhook endpoint from one that's easy to exploit.

If you're integrating with services like Stripe, GitHub, Slack, Discord, Razorpay, or Shopify, signature verification isn't optional—it's the foundation of trusting incoming events.

Take the time to implement it correctly today, and you'll avoid a class of security vulnerabilities that are surprisingly common in real-world applications.