Blog/Security
SecurityMarch 23, 2026 · 8 min read

HMAC Signatures for Webhooks — Stop Trusting Raw POST Bodies

A Stripe engineer once told me that the most common webhook integration mistake they see is developers who verify the event type but never verify the signature. Their endpoint accepts any POST request from anywhere on the internet, processes it as if Stripe sent it, and moves on. That's not a theoretical risk — it's an auth bypass waiting to happen.

Use HMAC-SHA256 with a shared secret to sign your webhook payloads, and always validate that signature before processing anything. The rest of this article shows you exactly how to implement both sides correctly, and where even experienced developers get it wrong.

How Webhook Signing Works

The sending service (Stripe, GitHub, Shopify, etc.) holds a secret key that only you and they know. When they dispatch a webhook, they compute an HMAC over the raw request body using that secret and include the result in a request header — typically X-Hub-Signature-256, Stripe-Signature, or something similar.

Sender and receiver use identical body bytes — signatures match.

Sender
Secret
my-webhook-secret-key
Raw Body (sent)
{"event":"order.created","orderId":42}
HMAC-SHA256 Output
sha256=—
Receiver
Secret
my-webhook-secret-key
Raw Body (received)
{"event":"order.created","orderId":42}
Recomputed HMAC
sha256=—

On your end, you recompute the HMAC using the same secret and the raw body you received, then compare your result to the one in the header. If they match, the payload is authentic and unmodified. If they don't, something's wrong — either the secret is mismatched, the body was tampered with, or the request didn't come from the legitimate sender.

The signature covers the body bytes, not the parsed object. That distinction matters more than most developers realize.

Generating HMAC Signatures

If you're building the sending side — your own webhook system, an internal event bus, or a public API — here's how to generate signatures correctly.

const crypto = require('crypto');

function signWebhookPayload(secret, body) {
// body should be the raw string you're going to send
return crypto
  .createHmac('sha256', secret)
  .update(body, 'utf8')
  .digest('hex');
}

const secret = process.env.WEBHOOK_SECRET;
const payload = JSON.stringify({ event: 'order.created', orderId: 42 });
const signature = signWebhookPayload(secret, payload);

// Attach to your outgoing request
headers['X-Webhook-Signature'] = `sha256=${signature}`;

Validating Incoming Signatures

This is where most implementations break down. The key rule: read the raw bytes from the request body before any parsing happens. Once your framework deserializes the JSON, you've lost the original byte sequence — and even a single character difference (a space, key reordering, trailing newline) will produce a completely different HMAC.

In Express, that means using express.raw() or reading req.rawBody before express.json() touches it. In Django, use request.body directly. In Spring, read the HttpServletRequest input stream before any @RequestBody binding.

const crypto = require('crypto');
const express = require('express');
const app = express();

// Use raw body middleware for webhook routes
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.headers['x-webhook-signature'];
const secret = process.env.WEBHOOK_SECRET;

if (!sigHeader) {
  return res.status(401).send('Missing signature');
}

const rawBody = req.body; // Buffer, not parsed object
const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(rawBody)
  .digest('hex');

// CRITICAL: use timingSafeEqual to prevent timing attacks
const sigBuffer = Buffer.from(sigHeader, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');

if (sigBuffer.length !== expectedBuffer.length ||
    !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
  return res.status(401).send('Invalid signature');
}

const event = JSON.parse(rawBody);
// Now safe to process
res.sendStatus(200);
});

The Timing Attack You're Probably Missing

If you're comparing signature strings with ===, ==, or .equals(), you're vulnerable to a timing attack. A sufficiently motivated attacker can measure response time differences to brute-force signatures one byte at a time.

The fix is a constant-time comparison function. Every language has one: crypto.timingSafeEqual() in Node.js, hmac.compare_digest() in Python, MessageDigest.isEqual() in Java, hash_equals() in PHP. These functions always compare the full length regardless of where the strings differ.

This isn't theoretical paranoia — it's a published attack class with proof-of-concept tooling. At high enough request volumes, the timing signal is measurable.

Replay Attacks and Timestamp Validation

A valid signature doesn't prove the request is fresh. An attacker who captures a legitimate webhook can replay it five minutes — or five days — later. Stripe's SDK defends against this by including a timestamp in the signature header and rejecting requests older than five minutes.

Add a timestamp to your signature payload:

X-Webhook-Timestamp: 1711234567
X-Webhook-Signature: sha256=abc123...

On the receiving end, include the timestamp in what you sign: timestamp + "." + body. Then reject any request where the timestamp is more than 5 minutes old. This makes captured signatures useless after a short window.

Rotating Secrets Without Downtime

When you rotate your webhook secret — and you should rotate it periodically, or immediately after any suspected exposure — you'll have a gap where in-flight requests were signed with the old key but your validator is already using the new one.

The clean solution: briefly accept signatures from both the old and new secret during rollover. Validate against the new key first; if that fails, try the old key. Log when the old key is used so you know when it's safe to fully retire it. Most major webhook platforms (including Stripe) do exactly this with dual-signature headers.

If you need to generate a strong secret for your webhook system right now, the EncryptCodec HMAC tool lets you compute HMAC-SHA256 signatures in-browser — useful for testing your implementation against a known-good reference before you ship.

Share this post

Frequently Asked Questions

Related posts

Prompt Injection: Why You Can't Fix It With a Better Prompt

Prompt injection isn't a filtering problem you can prompt your way out of. It's an architecture problem — the model can't tell your instructions from the data it's reading. Here's what actually contains it.

Jul 28, 2026 · 11 min read

Phishing URLs: How Attackers Fake Domains (and How to Read Them)

Typosquatting, homograph attacks, subdomain deception, and the @ trick — learn the handful of URL tricks behind most phishing, and the one habit that beats all of them.

Jun 13, 2026 · 7 min read

What Is CSV Injection — and How to Stop It in Your Export Feature

CSV (formula) injection turns a harmless 'export to spreadsheet' button into code execution on whoever opens the file. Here's how the attack works and how to prevent it.

Jun 13, 2026 · 7 min read