DEV Community

Cover image for Most Webhook Signatures Are Broken
Yusufhan Sacak
Yusufhan Sacak

Posted on

Most Webhook Signatures Are Broken

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" }
Enter fullscreen mode Exit fullscreen mode

and

{"amount":4999,"currency":"usd"}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Example:

v1:1700000000:nonce_abc123:{"event":"payment.completed","amount":4999}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

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

If you’ve ever debugged a “signature mismatch” at 2am, you’ll know why this matters.

Top comments (0)