DEV Community

SEN LLC
SEN LLC

Posted on

HMAC Webhook Signing Isn't Complicated — the Formats Are

HMAC Webhook Signing Isn't Complicated — the Formats Are

A tiny zero-dependency TypeScript CLI that signs and verifies webhook payloads in the three flavors you actually see in the wild: raw HMAC, GitHub's sha256=<hex>, and Stripe's t=...,v1=.... No runtime dependencies. 53 tests. One multi-stage Docker image.

📦 GitHub: https://github.com/sen-ltd/webhook-signer

Screenshot

The problem

Every time I wire up a new webhook consumer I end up doing the same awkward dance. I need to hit POST /hook on my local server, but the server rejects anything that doesn't carry a valid HMAC signature, and the only thing that can produce a valid signature is the exact signing logic the real provider uses. There are three ways out of this:

  1. Expose my local server to the internet and actually trigger a real event from the provider's dashboard.
  2. Open the provider's source code (if you're lucky enough that it's open), copy the signing function, and compute a signature by hand in a throwaway Node REPL.
  3. Write a small CLI that knows the signing formats and call it from a shell.

Option 3 wins for me, and I have rewritten this CLI three times on three different laptops. So this time I made it a public tool and put it in a Docker image so I can alias ws='docker run --rm -v "$PWD":/w webhook-signer' and stop rewriting it.

The interesting thing about writing it is that HMAC itself — the actual cryptography — is genuinely easy. Node's stdlib does it in one line:

import { createHmac } from 'node:crypto';
const sig = createHmac('sha256', secret).update(body).digest('hex');
Enter fullscreen mode Exit fullscreen mode

Everything else in webhook-signer is envelope work: deciding exactly what bytes go into .update(), what string format comes out, which HTTP header it goes in, and how the receiver is supposed to compare a received signature against a freshly computed one. Those four questions have three different answers depending on whether you're talking to GitHub, Stripe, or whoever rolled their own.

The three flavors

Here's the table that actually matters:

Flavor What's signed Output format Example header
raw body bytes <hex> X-Signature: deadbeef...
github body bytes sha256=<hex> X-Hub-Signature-256: sha256=...
stripe ${t}.${body} t=<unix>,v1=<hex> Stripe-Signature: t=...,v1=...

That's it. That's the whole problem space. A webhook-signing CLI for the everyday case is essentially a three-branch switch. I split each flavor into its own file anyway, because the differences matter more than the similarities.

Raw

// src/flavors/raw.ts
import { createHmac } from 'node:crypto';

export type HmacAlg = 'sha256' | 'sha1' | 'sha512';

export interface RawSignInput {
  secret: string;
  body: Buffer;
  alg?: HmacAlg;
}

export function rawSign(input: RawSignInput): string {
  const alg = input.alg ?? 'sha256';
  const mac = createHmac(alg, input.secret);
  mac.update(input.body);
  return mac.digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

Plain hex digest, nothing else. This is what openssl dgst -hmac gives you, and it's what a lot of in-house webhook systems use because "we already have a shared secret, let me HMAC the body and move on with my life."

GitHub

GitHub wraps the same hex in sha256= (or sha1= for the legacy X-Hub-Signature header). The only thing the wrapper does is let you migrate between hash algorithms without breaking the header contract. I'll show this one later; it's a one-liner on top of rawSign.

Stripe — the interesting one

Stripe does something clever. Here's the actual signer:

// src/flavors/stripe.ts
import { createHmac } from 'node:crypto';
import type { HmacAlg } from './raw.js';

export interface StripeSignInput {
  secret: string;
  body: Buffer;
  timestamp?: number; // unix seconds, defaults to now()
  alg?: HmacAlg;
}

export function stripeSign(input: StripeSignInput): string {
  const alg = input.alg ?? 'sha256';
  const t = input.timestamp ?? Math.floor(Date.now() / 1000);
  const signed = Buffer.concat([
    Buffer.from(`${t}.`, 'utf8'),
    input.body,
  ]);
  const hex = createHmac(alg, input.secret).update(signed).digest('hex');
  return `t=${t},v1=${hex}`;
}
Enter fullscreen mode Exit fullscreen mode

Notice what goes into .update(): not the raw body, but ${t}. concatenated with the body. The literal period between them is part of the spec. At first glance this looks pointlessly fussy — you're HMAC-ing a weird mush of timestamp and JSON. Why would you do that instead of HMAC-ing the body and shipping the timestamp alongside?

Because of replay.

Imagine you use raw HMAC and publish the signature in an X-Signature header while passing the timestamp separately in X-Timestamp. An attacker captures a valid (body, X-Signature, X-Timestamp) tuple off the wire (or reads it out of your log aggregator, which is the more realistic attack in 2026). Your receiver dutifully verifies the signature against the body and accepts it. What stops the attacker from replaying that tuple to you tomorrow? Only whatever logic you build on top, and you probably didn't build it, because you were just trying to get a webhook working.

Stripe's format makes that attack harder because the timestamp is inside the signed material. The receiver recomputes HMAC(secret, "${t}.${body}") using the t= they received and compares. If the attacker tries to replay yesterday's tuple:

  • They can't bump the t= to today, because that would need a new HMAC (which they can't produce without the secret).
  • They can't leave yesterday's t=, because the receiver will see it's older than the tolerance window and reject.

So a 5-minute tolerance window plus an in-MAC timestamp together get you practical replay protection without any state on the receiver. That's a real win over vanilla raw HMAC for anything customer-facing, and it's worth understanding before you pick a format for your own service.

The verifier: why === is wrong

Here's the top-level verifier. All three flavors dispatch through this:

// src/verifier.ts
import { timingSafeEqual } from 'node:crypto';
import { rawSign, type HmacAlg } from './flavors/raw.js';
import { githubParse } from './flavors/github.js';
import { stripeParse, stripeRecompute } from './flavors/stripe.js';

export function hexEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const ab = Buffer.from(a, 'hex');
  const bb = Buffer.from(b, 'hex');
  if (ab.length !== bb.length || ab.length === 0) return false;
  return timingSafeEqual(ab, bb);
}

export function verify(opts: VerifyOptions): boolean {
  switch (opts.flavor) {
    case 'raw': {
      const expected = rawSign({ secret: opts.secret, body: opts.body, alg: opts.alg });
      return hexEqual(expected, opts.signature.trim());
    }
    case 'github': {
      const parsed = githubParse(opts.signature.trim());
      if (!parsed) return false;
      const expectedHex = rawSign({ secret: opts.secret, body: opts.body, alg: parsed.alg });
      return hexEqual(expectedHex, parsed.hex);
    }
    case 'stripe': {
      const parsed = stripeParse(opts.signature.trim());
      if (!parsed) return false;
      const tolerance = opts.toleranceSeconds ?? 300;
      const nowSec = Math.floor((opts.now ? opts.now() : Date.now()) / 1000);
      if (Math.abs(nowSec - parsed.t) > tolerance) return false;
      const alg = opts.alg ?? 'sha256';
      const expected = stripeRecompute(opts.secret, opts.body, parsed.t, alg);
      for (const candidate of parsed.v1) {
        if (hexEqual(expected, candidate)) return true;
      }
      return false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things are worth pointing out.

1. timingSafeEqual, not ===

crypto.timingSafeEqual(a, b) walks both buffers to the end regardless of where the first difference is. Plain string equality — in JavaScript, in C, in pretty much every language — short-circuits on the first mismatched byte, which leaks information through timing: by measuring how long the comparison took, an attacker can learn how many leading bytes they guessed correctly. Given enough tries, that's enough to bisect the full signature one byte at a time. This is a real attack and it has been used in real CVEs.

timingSafeEqual has one sharp edge that bites everyone once: it throws a RangeError if the two buffers are different lengths. That's why hexEqual checks a.length !== b.length first and returns false. And one test I wouldn't be comfortable shipping without:

it('hexEqual: different-length strings return false without throwing', () => {
  expect(hexEqual('deadbeef', 'deadbeefaa')).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

Is it a leak to early-exit on length? No, because for a given hash algorithm the expected signature length is public and constant (sha256 is always 32 bytes, always 64 hex chars). The length carries no information about the secret.

2. Handle multiple v1 values

Look at the stripe branch: I loop over parsed.v1 because Stripe's header can contain multiple v1= entries during secret rotation. If you hardcode parsed.v1[0] you'll break verification on the day Stripe rotates endpoint secrets and sends two signatures in one header, and you won't notice until an event you cared about gets silently rejected. Same reason I continue on unknown keys like v0= instead of rejecting the whole header: Stripe explicitly says to.

The CLI

The main entry point is a standard subcommand dispatcher. The interesting subcommands are headers and curl, which exist for the one realistic workflow I actually use:

# I wrote a handler that expects a GitHub-flavored webhook. I want to hit it
# with curl from my terminal. Give me the exact flag to paste.
$ webhook-signer headers --flavor github --secret shh --body-file body.json
-H "X-Hub-Signature-256: sha256=98077c961cadf6f7b4370d008e340cfdc2d3389953cccd79a0c282d8ae1a1cab"

# Or build me the full curl command.
$ webhook-signer curl --flavor github --secret shh \
    --url http://localhost:3000/hook --body-file body.json
curl \
  -X POST \
  'http://localhost:3000/hook' \
  -H 'Content-Type: application/json' \
  -H 'X-Hub-Signature-256: sha256=98077c961cadf6f7b4370d008e340cfdc2d3389953cccd79a0c282d8ae1a1cab' \
  --data-binary @body.json
Enter fullscreen mode Exit fullscreen mode

The curl subcommand prints the command; it doesn't execute it. That's deliberate: when something's wrong in my handler I want to see the exact curl I'm about to run, tweak the URL or change a header, and run it myself. A tool that silently fires HTTP requests for you is a tool you can't trust in a debugging session.

Tradeoffs and what's explicitly out

  • HMAC only. Some webhook producers sign with RSA (AWS SNS) or ECDSA (Apple push receipts). Those are a different primitive, not a different flavor of the same one, and handling them would roughly double the surface area of this tool. If you need them, a separate command.
  • No body canonicalization. A few providers — certain Slack variants, some signed-JWT webhooks — canonicalize the JSON (sort keys, strip whitespace) before signing. webhook-signer signs the bytes you hand it, byte for byte. If your provider normalizes, you normalize first.
  • --tolerance has no floor. You can set it to 0, which means the stripe-flavor signature is only valid during the exact UTC second it was produced. That's usually wrong, but I'm not going to second-guess you in a dev tool.
  • Zero runtime deps, on purpose. The package.json has no dependencies key, only devDependencies. This tool will keep working on Node 20+ forever — nothing to npm audit, nothing to upgrade, nothing to silently break when a transitive release breaks semver. For a tool I want to ship as a Docker image and not touch again for a year, that matters.

Try it in 30 seconds

git clone https://github.com/sen-ltd/webhook-signer.git
cd webhook-signer
docker build -t webhook-signer .

mkdir -p /tmp/ws && echo '{"event":"push","ref":"main"}' > /tmp/ws/body.json

# Sign
docker run --rm -v /tmp/ws:/work webhook-signer \
  sign --flavor github --secret shh --body-file /work/body.json

# Round-trip
SIG=$(docker run --rm -v /tmp/ws:/work webhook-signer \
  sign --flavor github --secret shh --body-file /work/body.json)
docker run --rm -v /tmp/ws:/work webhook-signer \
  verify --flavor github --secret shh --signature "$SIG" --body-file /work/body.json
# → valid (exit 0)

# Wrong secret
docker run --rm -v /tmp/ws:/work webhook-signer \
  verify --flavor github --secret wrong --signature "$SIG" --body-file /work/body.json
# → invalid (exit 1)
Enter fullscreen mode Exit fullscreen mode

The full 136 MB image contains the compiled JS, a non-root user, and nothing else. You can paste this into any CI pipeline or throw it behind a shell alias.


Repo: https://github.com/sen-ltd/webhook-signer · License MIT · TypeScript strict · 53 tests · zero runtime deps.

Top comments (0)