Skip to main content
{/}codefunc
Web & APIJuly 28, 2026 · 6 min read

HMAC Request Signing for Webhooks: Canonical Strings, Timing-Safe Compare, and Fixtures

How HMAC signatures protect webhooks — canonical message construction, hex vs Base64 encodings, timing-safe comparison concepts, and building reliable test fixtures.

By codefunc

hmacwebhookssecuritysignaturesweb-crypto

Webhook providers prove authenticity with a shared secret and an HMAC tag over the request body (and sometimes a timestamp). Implementing verification looks short — a few lines of crypto — yet production outages cluster around the same mistakes: pretty-printed JSON, wrong encoding, naive string compare, or clocks that reject every valid retry. This guide focuses on the engineering details that make signatures verify reliably.

Bottom line: HMAC authenticates a message with a shared secret. Sign the exact raw bytes the provider signed, encode the tag the way the docs specify (hex or Base64), reject stale timestamps, and compare tags in a timing-safe way on the server. Use browser HMAC tools only with non-production secrets for fixtures.

Who this guide is for

Backend and full-stack developers verifying Stripe-style, GitHub-style, or custom webhooks — and frontend engineers debugging why a local mock signature never matches. Familiarity with SHA-256 helps; see also the hashing guide for digest vs HMAC.

HMAC in one paragraph

HMAC (Hash-based Message Authentication Code) combines a secret key with a hash function (commonly SHA-256):

tag = HMAC-SHA256(secret, message)

Anyone with the secret can produce or verify the tag. Anyone without the secret should not be able to forge a valid tag for a chosen message. HMAC does not encrypt the body — pair it with TLS in transit.

Property Bare SHA-256 of body HMAC-SHA256
Detects accidental corruption Yes Yes
Detects attacker who can rewrite body + checksum No Yes (needs secret)
Needs a shared secret No Yes

Debug digests with a Hash Generator; debug keyed tags with an HMAC Generator.

Canonical message construction

Providers differ on what string/bytes get signed. Always read their docs. Common patterns:

Pattern Message input Example header
Raw body only Exact HTTP body bytes X-Signature: sha256=…
Timestamp + body t + "." + body Stripe-style t=…,v1=…
Method + path + body Custom canonical string Internal APIs
Body hash then outer scheme Hash of body embedded in signed string Some enterprise APIs
// Stripe-inspired shape (illustrative)
const signedPayload = `${timestamp}.${rawBody}`;
const expected = hmacSha256Hex(secret, signedPayload);

Critical rule: do not re-serialize JSON before verifying. {"a":1,"b":2} and {"b":2,"a":1} are different byte sequences. Capture the raw body from the request framework (disable automatic parsing until after verify, or keep a raw copy).

Hex, Base64, and prefixes

The HMAC output is raw bytes. Providers encode them differently:

Encoding Looks like Typical use
Hex a3f2… (64 chars for SHA-256) GitHub X-Hub-Signature-256: sha256=…
Base64 o/I… Many enterprise webhooks
Base64url URL-safe variant Token-like contexts

Prefixes such as sha256= are not part of the HMAC input; strip them before compare. When a doc says Base64, decode carefully — use a Base64 tool to inspect fixtures, and confirm whether padding = is expected.

// GitHub-style header: "sha256=<hex>"
const received = header.replace(/^sha256=/, "");

Timing-safe comparison concepts

A vulnerable compare:

// Vulnerable to timing attacks in theory
if (received === expected) accept();

On many platforms, short-circuiting string equality can leak information about how far two strings match. Use a constant-time compare for secret-derived tags:

import { timingSafeEqual } from "node:crypto";

function safeEqualHex(a, b) {
  const ba = Buffer.from(a, "hex");
  const bb = Buffer.from(b, "hex");
  if (ba.length !== bb.length) return false;
  return timingSafeEqual(ba, bb);
}

In browsers / Web Crypto verification flows, prefer verified libraries or compare digests of both tags with care — production webhook verification belongs on the server anyway. Length checks must not early-return in a way that skips a constant-time compare on equal-length buffers; handle unequal lengths without leaking the expected tag.

Also reject obviously wrong lengths before compare when the algorithm is fixed (SHA-256 hex = 64 chars), but still avoid verbose error messages that distinguish "bad length" from "bad mac" to anonymous clients if that aids attackers — many teams still log details server-side only.

Timestamps and replay windows

Signing timestamp + body lets you reject replays:

  1. Parse t from the header.
  2. Reject if |now - t| exceeds tolerance (often 5 minutes).
  3. Only then verify HMAC.

Without a timestamp, a captured valid request could be replayed until the secret rotates. Idempotency keys at the application layer remain useful even with HMAC.

Building test fixtures

Reliable fixtures beat ad-hoc curl:

  1. Pick a test-only secret (whsec_test_…), never a live key.
  2. Freeze a body string exactly (include whitespace).
  3. Compute the tag with the HMAC Generator or a small script.
  4. Store { secret, body, timestamp, header } in the test suite.
  5. Assert both success and failure cases (wrong secret, mutated body, expired timestamp).
// Fixture sketch
const secret = "test_secret_do_not_use_live";
const body = '{"event":"order.created","id":"ord_1"}';
const timestamp = "1700000000";
const message = `${timestamp}.${body}`;
// expectedHex from HMAC-SHA256(secret, message)

When the provider publishes official test vectors, prefer those. When encoding is ambiguous, check intermediate values: hash of body (if any) in Hash Generator, Base64 of the tag in Base64, full HMAC in HMAC Generator.

Server checklist

  • Read raw body bytes for the signed message
  • Use the documented algorithm (usually HMAC-SHA256)
  • Match encoding and header prefix rules
  • Enforce timestamp tolerance when the scheme includes time
  • Compare with timingSafeEqual (or equivalent)
  • Keep secrets in env / secret manager — not in frontend bundles
  • Rotate secrets when a leak is suspected; support dual-secret overlap if the provider allows

What not to do

Anti-pattern Why it fails
Pretty-print then verify Byte mismatch
Put the webhook secret in a SPA Users could forge "verified" calls to your own APIs
Use bare SHA-256 of the body as auth Attackers recompute it
Log full secrets or full signed payloads in plaintext Leakage via log drains
Skip TLS because "we have HMAC" Body confidentiality and path security still need HTTPS

Practical takeaway

Treat webhook verification as strict byte protocol work: canonical message, documented encoding, timestamp window, timing-safe compare. Build fixtures with disposable secrets using the HMAC Generator, inspect encodings with Base64, and reserve the Hash Generator for non-keyed digests. Production secrets stay on the server; browser tools are for learning and offline fixture craft.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool