HMAC-SHA256 Webhook Signature Verification: RFC 2104 Internals and Provider Formats

Sep 6, 2026·
toolbox-editorial-team
· 10 min read
blog
Interactive Workbench LIVE

HMAC-SHA256 Webhook Signature Verification: RFC 2104 Internals and Provider Formats

Initializing Workbench...
100% Client-Side RAM Sandbox
🔒 Private Execution: Zero server uploads.

The Problem HMAC Solves

You receive an HTTP POST claiming to be a payment confirmation. The body says an order was paid. Acting on it moves inventory and money. Two questions must be answered before your code touches the database:

  1. Authenticity — did this come from the provider, or from anyone who found the endpoint URL?
  2. Integrity — is the body byte-for-byte what the provider sent, or was an amount edited in transit?

A Keyed-Hash Message Authentication Code answers both. The provider holds a secret, you hold the same secret, and the provider attaches a tag that only a holder of that secret could have produced over that exact payload.

What you get from this guide: the construction, the specific attack that motivates it, working verification code for three major providers, and the four mistakes that cause almost every “signature invalid” incident.


The RFC 2104 Construction

HMAC(K, m) = H( (K' XOR opad) || H( (K' XOR ipad) || m ) )
TermDefinition
HUnderlying hash function (SHA-256, SHA-512, SHA-1)
BHash block size in bytes — 64 for SHA-1/SHA-224/SHA-256, 128 for SHA-384/SHA-512
K'The key, normalised to exactly B bytes
ipadByte 0x36 repeated B times (inner pad)
opadByte 0x5C repeated B times (outer pad)
`

Key normalisation matters: a key shorter than B is right-padded with zero bytes; a key longer than B is first hashed with H, and that digest becomes the key. This means a 200-character secret and its SHA-256 digest produce identical HMACs — a detail that occasionally explains why two “different” secrets validate the same payload.

Why not H(secret || message)?

Because SHA-1 and the SHA-2 family are Merkle–Damgård constructions that expose their internal state as the output digest. Given H(secret || message) and the length of secret || message, an attacker can resume the hash from that state and compute a valid digest for secret || message || padding || evil — without ever learning the secret. That is a length-extension attack, and it turns a naive “signature” into a forgery oracle.

Block sizes and digest lengths

Hash functionBlock size BDigestHex characters
MD564 bytes128 bits32
SHA-164 bytes160 bits40
SHA-25664 bytes256 bits64
SHA-384128 bytes384 bits96
SHA-512128 bytes512 bits128

The two pads are the whole trick: ipad and opad differ in exactly half their bits, so the inner and outer passes behave like two independent keyed functions.

The double-pad nesting in HMAC means the attacker only ever sees the output of the outer hash, whose input they cannot control or extend. This is why RFC 2104 (1997) and FIPS PUB 198-1 (2008) both standardise the nested form, and why crypto.createHmac exists in every standard library.


Provider Formats Compared

ProviderHeaderEncodingSigned dataSecret
StripeStripe-Signaturehex, inside t=…,v1=…"{timestamp}.{raw_body}"endpoint secret (whsec_…)
GitHubX-Hub-Signature-256hex, prefixed sha256=raw bodywebhook secret
ShopifyX-Shopify-Hmac-SHA256Base64raw bodyapp client secret

All three use HMAC-SHA256 and all three require the unmodified raw body.

Stripe

The header carries a timestamp and one or more scheme-prefixed signatures:

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verification, per Stripe’s manual procedure: split the header on , then =, build signed_payload as the timestamp, a literal ., and the raw JSON body, compute HMAC-SHA256 with the endpoint secret, compare in constant time, and reject if the timestamp is outside your tolerance. Stripe’s official libraries default to a five-minute tolerance, and Stripe advises ignoring any scheme that is not v1 to prevent downgrade attacks. During a secret roll, an endpoint can have two active secrets and the header then carries one v1 signature per secret — so compare against all v1 values, not just the first.

import crypto from "node:crypto";

export function verifyStripe(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
  );
  const timestamp = Number(parts.t);
  if (!timestamp) return false;

  // Freshness: a valid signature is not a fresh one.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  const received = header
    .split(",")
    .filter((kv) => kv.trim().startsWith("v1="))
    .map((kv) => kv.trim().slice(3));

  return received.some((sig) => timingSafeEqualHex(expected, sig));
}

function timingSafeEqualHex(a, b) {
  const ab = Buffer.from(a, "hex");
  const bb = Buffer.from(b, "hex");
  return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}

GitHub

export function verifyGithub(rawBody, header, secret) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Shopify

export function verifyShopify(rawBody, header, clientSecret) {
  const expected = crypto
    .createHmac("sha256", clientSecret)
    .update(rawBody, "utf8")
    .digest("base64");
  const a = Buffer.from(expected, "base64");
  const b = Buffer.from(header ?? "", "base64");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Advertisement Sponsored

The Four Failure Modes

1. The body was parsed before you hashed it

The most common cause by a wide margin. express.json(), Django’s request handling, and Next.js route defaults may hand you a re-serialised object. Re-encoding {"a":1.0} as {"a":1}, reordering keys, or normalising Unicode escapes all change the byte string and therefore the digest. Stripe states the requirement plainly: the raw body must be the string it sent, in UTF-8, unchanged.

// Order matters. Mount the raw-body webhook route BEFORE the JSON parser.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler);
app.use(express.json());

2. Non-constant-time comparison

expected === received returns as soon as it hits a differing byte. That timing difference is measurable across enough requests and leaks the expected digest byte by byte. Use crypto.timingSafeEqual, hmac.compare_digest (Python), or hash_equals (PHP). Compare decoded bytes of equal length — timingSafeEqual throws on length mismatch, so check length first.

3. No replay protection

A valid signature says “this was authentic when created”. It says nothing about when. Sign a timestamp (Stripe’s model) and enforce a tolerance, or store processed delivery IDs and drop repeats. Stripe also warns against setting the tolerance to 0, which disables the recency check entirely.

4. Signature comparison as the only gate

Also validate that the event type is one you handle, return 2xx quickly and process asynchronously, and treat delivery as at-least-once — providers retry, and duplicate events are normal. Stripe explicitly does not guarantee ordering, so deduplicate on event ID rather than trusting timestamps.


Hex, Base64, or Base64url — Match the Provider Exactly

One 32-byte HMAC-SHA256 tag has several textual forms, and mismatched encoding is the second most common cause of failed verification after raw-body mistakes.

EncodingLength for SHA-256Typical users
Lowercase hex64 charactersGitHub, Stripe, most REST APIs
Base6444 characters (with = padding)Shopify, Slack legacy, AWS headers
Base64url43 characters (no padding)JWT HS256 signature segment

A digest that “looks wrong” but is the right length in a different alphabet is an encoding bug, not a secret mismatch. For the encoding mechanics themselves see the Base64 and data URI guide; for the JWT variant, the JWT security guide.


Computing HMAC in the Browser

SubtleCrypto supports HMAC natively, so a signature can be produced or checked with no network exposure at all:

async function hmacSha256Hex(secret, message) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

For verification prefer crypto.subtle.verify("HMAC", key, signatureBytes, data) — it compares internally instead of exposing you to a naive ===.


Operational Checklist

  1. Generate keys randomly. 32 bytes from crypto.getRandomValues or openssl rand -hex 32 — never a passphrase.
  2. Sign the exact bytes on the wire. No re-serialisation, trimming, or charset conversion.
  3. Compare in constant time. crypto.timingSafeEqual, Python’s hmac.compare_digest, PHP’s hash_equals, or subtle.verify.
  4. Include a timestamp and reject stale requests. HMAC proves authenticity, not freshness.
  5. Support two active secrets during rotation. Accept either tag while migrating, then retire the old key — this is exactly why Stripe can send one v1 signature per active secret.
  6. Never log the secret or put it in a URL. Query strings land in access logs, proxies, and browser history.

Choosing the Hash

AlgorithmDigestVerdict for new work
HMAC-SHA25632 bytesDefault. Universal library support, ample margin.
HMAC-SHA51264 bytesFine; faster than SHA-256 on 64-bit CPUs for large payloads.
HMAC-SHA38448 bytesUse when a compliance profile requires it.
HMAC-SHA120 bytesLegacy interop only (older GitHub X-Hub-Signature, TOTP). Not broken inside HMAC, but do not choose it for new protocols.

HMAC-SHA1 remains safe as an authenticator because HMAC does not depend on collision resistance — the same reason TOTP still uses HMAC-SHA1. Even so, auditors flag it, so new designs should start at SHA-256. For plain (unkeyed) integrity digests, see the SHA-256 and MD5 hashing guide.


Step-by-Step: Debugging a Failing Signature with Toolbox

  1. Capture the raw body from your server logs or the provider’s delivery inspector — copy the exact bytes, not a pretty-printed version.
  2. Open the tool: visit the Toolbox HMAC Generator & Verifier.
  3. Paste the payload and the signing secret, and select SHA-256.
  4. For Stripe, prepend the timestamp: enter 1492774577.{"id":"evt_…"} — timestamp, dot, raw body — matching the signed_payload construction.
  5. Compare digests: place the provider’s header value next to the computed digest. A hex/Base64 mismatch means an encoding bug; a completely different digest means the body was mutated or the wrong secret is in use.

Outcome: you localise the fault to one of three causes — wrong secret, mutated body, or wrong signed-payload construction — in a couple of minutes, without redeploying to add log statements.

Related guides: TOTP 2FA internals · JWT signature and claims · AES-256-GCM authenticated encryption

FAQ

Frequently Asked Questions

Why is HMAC used instead of simply hashing the secret and the message together?

SHA-1 and SHA-2 are Merkle-Damgard constructions vulnerable to length extension: given H(secret || message) and the input length, an attacker can produce a valid digest for message || padding || attacker_data without knowing the secret. HMAC's nested form, H((K XOR opad) || H((K XOR ipad) || message)), prevents that, which is why RFC 2104 and FIPS PUB 198-1 specify it.

Why does webhook signature verification fail when my framework parses JSON automatically?

The digest covers the exact bytes the provider sent. Body parsers re-serialise JSON and can reorder keys or change whitespace, number, and Unicode formatting, producing a different byte string and digest. Capture the raw body before parsing middleware runs — in Express, mount the webhook route before express.json() or use express.raw() on that route.

What is the difference between HMAC and a digital signature?

HMAC is symmetric: the same secret creates and verifies the tag, so any verifier can also forge. It proves the sender holds the shared secret. RSA or ECDSA signatures are asymmetric — the private key signs, the public key only verifies — adding non-repudiation. Webhooks use HMAC because a shared secret already exists and HMAC is far faster.

Do I still need a timestamp check if the signature is valid?

Yes. A valid signature proves authenticity and integrity, not freshness. Without a signed timestamp an intercepted request can be replayed indefinitely. Stripe signs a timestamp and its libraries default to a five-minute tolerance; where no timestamp is signed, deduplicate on the delivery or event ID.