Here’s a Correct, Production-Grade Way to Do It
Webhooks look simple.
You receive a POST request, parse the JSON, check a signature, and move on.
But in real production systems — especially in finance, billing, or automation — webhook security is one of the most commonly misunderstood and incorrectly implemented parts of backend engineering.
After working with Salesforce, Workato-style integrations, and custom webhook pipelines, I kept seeing the same problems repeated again and again.
So I built a small open-source library to fix them properly.
This article explains what usually goes wrong, what “correct” actually means, and how I implemented it in a generic, Stripe-style way.
The Illusion of Webhook Security
Most webhook implementations claim to be “secure” because they:
- Use HMAC
- Compare a signature
- Share a secret
But when you look closely, many of them:
- Parse the body before verification
- Skip timestamp validation
- Have no replay protection
- Use unsafe string comparisons
- Accidentally re-serialize JSON
All of these break the security model — sometimes silently.
Mistake #1: Not Signing the Raw Body
HMAC signs bytes, not objects.
This is subtle, but critical.
{ "amount": 4999, "currency": "usd" }
and
{"amount":4999,"currency":"usd"}
represent the same data — but not the same bytes.
If you parse JSON and re-stringify it, you change the byte sequence and the signature no longer matches.
Yet many webhook handlers do exactly this because body-parsing middleware runs before verification.
Correct rule:
Always verify the signature against the exact raw body received over the wire.
Verify first. Parse later.
Mistake #2: No Timestamp Validation
If a webhook has no timestamp, a valid request can be replayed forever.
Anyone who captures it once can resend it days, weeks, or months later — and your system will happily accept it.
This is not theoretical. It happens.
Correct rule:
Every webhook must include a timestamp, validated within a strict tolerance window.
Stripe uses ±5 minutes. That’s a good default.
Mistake #3: No Replay Protection (Even With Timestamps)
Even with timestamps, an attacker can replay a request within the allowed window.
This is especially relevant for:
- Payments
- State-changing events
- Idempotent-looking but non-idempotent logic
Correct rule:
Use a nonce (unique request ID) and reject duplicates.
The verification library should support this, but storage belongs to the consumer (Redis, DB, etc.).
Mistake #4: Unsafe Signature Comparison
Using === or string comparison leaks timing information.
It’s a classic footgun.
Correct rule:
Always use constant-time comparison (
crypto.timingSafeEqual).
Anything else is unacceptable in security-sensitive code.
A Stripe-Style Model (Without Stripe Lock-In)
Stripe gets this right. Their model is simple and effective:
- HMAC-SHA256
- Canonical string
- Timestamp
- Replay protection
- Minimal magic
I wanted the same model, but generic — usable for:
- Salesforce-style outbound webhooks
- Workato recipes
- Internal services
- Custom SaaS platforms
That’s why I built webhook-hmac-kit.
webhook-hmac-kit
webhook-hmac-kit is a lightweight, production-ready toolkit for signing and verifying webhook requests.
Design goals:
- Correctness over convenience
- No framework assumptions
- No hidden parsing
- No external crypto dependencies
Core features:
- HMAC-SHA256 signing
- Deterministic canonical string
- Timestamp validation
- Optional nonce-based replay protection
- Constant-time signature comparison
- TypeScript-first API
- Zero runtime dependencies
Canonical String Format
All signatures are computed over a canonical string:
v1:{timestamp}:{nonce}:{payload}
Example:
v1:1700000000:nonce_abc123:{"event":"payment.completed","amount":4999}
The payload is included verbatim.
No encoding. No normalization. No escaping.
This avoids ambiguity and makes cross-language verification reliable.
Signing a Webhook
import { signWebhook } from 'webhook-hmac-kit';
import crypto from 'node:crypto';
const payload = JSON.stringify({ event: 'payment.completed', amount: 4999 });
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomUUID();
const { signature } = signWebhook({
secret: 'whsec_your_secret',
payload,
timestamp,
nonce,
});
Verifying a Webhook (Correctly)
import { verifyWebhook } from 'webhook-hmac-kit';
await verifyWebhook({
secret: process.env.WEBHOOK_SECRET!,
payload: rawBody, // exact bytes received
signature: headers['x-webhook-signature'],
timestamp: Number(headers['x-webhook-timestamp']),
nonce: headers['x-webhook-nonce'],
nonceValidator: async (nonce) => {
const exists = await redis.exists(`nonce:${nonce}`);
if (exists) return false;
await redis.set(`nonce:${nonce}`, '1', 'EX', 300);
return true;
},
});
On failure, the library throws typed errors, so you can respond correctly (401, 400, 409) instead of guessing.
Why Not JWT?
JWTs are great for authentication.
They are not designed for signing arbitrary HTTP payloads.
Webhook signatures need to:
- Sign exact raw bytes
- Avoid JSON canonicalization issues
- Be cheap and fast to verify
HMAC is simpler, safer, and battle-tested for this use case.
Small, Boring, and Correct
This library is intentionally boring.
No decorators.
No magic middleware.
No hidden parsing.
Just the pieces you need to implement webhook security correctly.
Links
- GitHub: https://github.com/JosephDoUrden/webhook-hmac-kit
- npm: https://www.npmjs.com/package/webhook-hmac-kit
If you’ve ever debugged a “signature mismatch” at 2am, you’ll know why this matters.
Top comments (0)