GLOSSARY

What is an HMAC Signature?

An HMAC signature is a keyed hash of a message, computed as defined in RFC 2104, that proves the sender knew a shared secret and that the bytes were not altered in transit. It is normally sent in an HTTP header alongside the payload it covers.

Free forever plan · No credit card required · Cancel anytime

Quick definition

An HMAC signature is a keyed hash of a message, computed as defined in RFC 2104, that proves the sender knew a shared secret and that the bytes were not altered in transit. It is normally sent in an HTTP header alongside the payload it covers.

In a single sentence: a hash that only somebody holding the secret could have produced for exactly these bytes.

What it means

An HMAC, or Hash-based Message Authentication Code, answers two questions at once about a message that arrived over an untrusted path: did it come from someone who holds the shared secret, and are these the exact bytes they sent? It does not answer a third question, which is whether anybody read the message in transit. HMAC is authentication, not encryption. TLS is still doing the confidentiality work.

The construction is specified in RFC 2104 and standardised again as FIPS 198-1. It is deliberately boring: a hash function, a key, and two fixed padding constants.

The construction, and why it looks strange

The definition is:

HMAC(K, m) = H( (K' xor opad) || H( (K' xor ipad) || m ) )

where

  • H is the hash function, normally SHA-256.
  • K' is the key, hashed down if it is longer than the hash's block size and zero-padded up to that size if it is shorter. For SHA-256 the block size is 64 bytes, and the output is 32 bytes.
  • ipad is the byte 0x36 repeated to the block size, and opad is 0x5C repeated. The two constants differ in many bit positions on purpose, so the inner and outer keys are effectively independent.
  • || is concatenation.

The obvious thing, hashing the secret and the message together as H(secret || message), is broken on every Merkle-Damgard hash, which includes SHA-1 and all of SHA-2. Those functions publish their internal state as the digest, so an attacker holding a valid digest can resume the hash from that state, append whatever they like, and produce a valid digest for the extended message without ever learning the secret. That is a length-extension attack, and it is the entire reason for the outer hash: the outer hash consumes a finished digest, not a resumable state.

What gets signed, exactly

The most common integration failure is not cryptographic, it is about bytes. A signature covers a specific byte sequence. If your framework parses the JSON body and hands your handler an object, the bytes are gone, and re-serialising that object produces a different byte sequence with a different signature. Key order, whitespace, unicode escaping and float formatting all differ between serialisers.

The rule is absolute: verify against the raw body, before any parsing. In Express that means a raw body parser mounted on the webhook route ahead of the JSON parser. In ASP.NET it means enabling buffering and reading the stream before model binding. In a serverless handler it usually means asking the platform for the un-decoded body, which some of them base64 encode by default.

Three header shapes, and the one that stops replays

Signing the body proves the body is authentic. It does not stop somebody who captured a valid request from sending it again, byte for byte, tomorrow. The signature will still verify, because nothing about it expires.

The fix is to sign a timestamp along with the body, and the important word is along with. A timestamp in a separate unsigned header is decoration: an attacker replaying a request can simply edit it. When the timestamp is inside the signed string, it cannot be altered without invalidating the signature, and a receiver that rejects anything more than 300 seconds from its own clock has real replay resistance.

That is the shape the Pinlyx Data API's watch deliveries use. Each request carries a signature header formatted as t=<unix seconds>,v1=<hex>, and the value signed is the exact string <t>.<raw request body>, computed with HMAC-SHA256 under the endpoint's secret. Stripe's Stripe-Signature uses the same construction, which is why the verification code below transfers between them almost unchanged. GitHub's X-Hub-Signature-256 is the simpler bare-digest form, which is why GitHub receivers need their own replay handling.

Comparing in constant time

Once you have computed the expected digest, comparing it with == introduces a timing side channel. A normal string comparison returns as soon as it finds a differing byte, so a signature whose first byte is right takes measurably longer to reject than one whose first byte is wrong. Given enough attempts an attacker can recover a valid signature byte by byte.

Use the constant-time comparison your platform provides:

  • Node: crypto.timingSafeEqual(a, b). It throws if the buffers differ in length, so check the length first, or compare fixed-size decoded digests rather than the strings.
  • Python: hmac.compare_digest(a, b).
  • .NET: CryptographicOperations.FixedTimeEquals(a, b).
  • Go: hmac.Equal(a, b), or subtle.ConstantTimeCompare.

A practical trick when lengths may differ: HMAC both the candidate and the expected value again under a random per-process key, then compare those. The second HMACs are always the same length, and the comparison leaks nothing about the first.

Handling the secret

A signing secret is a symmetric key. Everything that matters follows from that:

  • Generate it from a cryptographically secure random source, 32 bytes minimum. A secret that is a memorable phrase is a secret that can be guessed offline at billions of attempts per second.
  • Store it where your other credentials live, not in the repo and not in a log line. A logged webhook secret is a forged webhook.
  • Fail closed. A request with no signature header must be rejected, not accepted as "probably a test". This is how verification gets silently disabled in staging and then deployed.
  • Scope it per destination. One secret per registered endpoint means one compromised receiver does not let anyone forge traffic to the others.

The Data API mints a webhook secret at endpoint registration and returns it exactly once, in that response. It is never readable again, and there is a hard maximum of 10 endpoints per account. The trade-off is explicit: no way to look the secret up later means no second copy to leak, at the cost of a re-registration when you rotate.

Related concepts

  • Webhook replay: why the timestamp has to be inside the signed string.
  • Bearer token: the other way to prove identity, and why a leaked signature is harmless while a leaked token is not.
  • Webhook: the delivery mechanism that makes signatures necessary in the first place.
  • Idempotency key: the other half of a safe receiver, once the request is proven authentic.

How Pinlyx handles it

Every outbound webhook from Pinlyx is HMAC-signed with a per-destination secret, carries a timestamp inside the signed value, and includes a stable event id so the receiver can de-duplicate. Endpoints must be https, because the signature proves a payload came from us and does nothing to keep anyone else from reading it in transit. Registration is verified with a signed probe before any real event is sent, so a key cannot be used to point an authenticated server at a third party.

Cheat sheet · the three header shapes

Same algorithm, very different replay properties.

ShapeHeaderSigned valueReplay resistance
Bare digestX-Hub-Signature-256: sha256=<hex>The raw request body only.None on its own. A captured request stays valid forever unless a separate timestamp header is signed too.
Digest plus separate timestampX-CRMS-Signature: sha256=<hex> and X-CRMS-TimestampThe raw body; the timestamp travels beside it.Partial. Reject stale timestamps, but remember the timestamp is only trustworthy if it is inside the signed string.
Combined scheme (recommended)t=<unix seconds>,v1=<hex>The exact string <t>.<raw body>.Strong. The timestamp cannot be changed without breaking v1, so a freshness window is meaningful.
Cheat sheet · verifying the combined scheme

The same six steps in Node, Python and C#.

Parse the header, check the age, rebuild the signed string, compute, compare in constant time, then process. All three verify against the raw bytes.

Node (Express)
import crypto from "node:crypto";
import express from "express";

// express.raw MUST come before any JSON parser on this route, or req.body
// is an object and the original bytes are unrecoverable.
app.post("/hooks", express.raw({ type: "*/*" }), (req, res) => {
  const header = req.header("X-Signature") || "";
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.trim().split("=")),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return res.status(400).end();

  // Freshness. The timestamp is inside the signed string, so it is trusted.
  if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).end();

  const signed = t + "." + req.body.toString("utf8");
  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET)
    .update(signed, "utf8")
    .digest();

  const given = Buffer.from(v1, "hex");
  if (given.length !== expected.length) return res.status(401).end();
  if (!crypto.timingSafeEqual(given, expected)) return res.status(401).end();

  res.status(200).end();                  // acknowledge fast
  queue.enqueue(JSON.parse(req.body.toString("utf8")));
});
Python (Flask)
import hashlib, hmac, os, time
from flask import request, abort

def verify() -> bytes:
    header = request.headers.get("X-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        abort(400)

    if abs(time.time() - t) > 300:
        abort(401)

    raw = request.get_data()               # bytes, not request.json
    signed = str(t).encode() + b"." + raw
    expected = hmac.new(
        os.environ["WEBHOOK_SECRET"].encode(), signed, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, v1):
        abort(401)
    return raw
C# (ASP.NET Core)
static bool Verify(string header, byte[] rawBody, string secret)
{
    var parts = header.Split(',')
        .Select(p => p.Split('=', 2))
        .Where(p => p.Length == 2)
        .ToDictionary(p => p[0].Trim(), p => p[1].Trim());

    if (!parts.TryGetValue("t", out var ts) ||
        !parts.TryGetValue("v1", out var v1) ||
        !long.TryParse(ts, out var unix)) return false;

    var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix);
    if (age > 300) return false;

    var prefix = Encoding.UTF8.GetBytes(unix + ".");
    var signed = new byte[prefix.Length + rawBody.Length];
    Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
    Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = hmac.ComputeHash(signed);
    var given = Convert.FromHexString(v1);

    // Length-safe and timing-safe.
    return CryptographicOperations.FixedTimeEquals(expected, given);
}
Watch out for

Seven ways verification silently stops working.

  • Verifying the parsed body instead of the raw bytes. Re-serialised JSON is different JSON.
  • A JSON body parser mounted ahead of the webhook route, which consumes the stream before you can read it.
  • Comparing with == or ===, which returns early and leaks the digest one byte at a time.
  • Accepting a request with no signature header, usually because a staging shortcut shipped.
  • A timestamp that is not inside the signed string, so an attacker can edit it during a replay.
  • Logging the payload including the secret, or storing the secret in the repository.
  • Assuming the signature is the whole answer. It proves origin, not freshness and not uniqueness; you still need a window and a de-duplication store.

HMAC signatures: FAQ

The cryptography, the byte handling, and the reasons a valid signature fails to verify.

Because H(secret || message) is broken on Merkle-Damgard hash functions, which includes SHA-1 and the whole SHA-2 family. Those hashes expose their internal state as the digest, so an attacker who has a valid digest can resume from that state, append data of their choosing and produce a valid digest for the longer message without ever knowing the secret. That is a length-extension attack. RFC 2104 defeats it with a nested construction: the outer hash consumes the inner digest rather than the internal state.
No, and the word "signature" causes real confusion here. HMAC is symmetric: the verifier holds the same secret as the signer, so the verifier could have produced the value themselves. That gives you authenticity and integrity but not non-repudiation, and it means the receiver cannot prove to a third party who sent the message. If you need that property, use an asymmetric signature such as Ed25519 or RSA, where only the holder of the private key could have produced the value.
SHA-256 unless something forces your hand. HMAC-SHA256 has a 64-byte block size and a 32-byte output, is available in every standard library, and is hardware-accelerated on modern CPUs. HMAC-SHA1 is still cryptographically acceptable as a MAC despite SHA-1 being broken for collision resistance, because HMAC does not depend on collision resistance, but there is no reason to choose it for new work. MD5 should be considered legacy in every context.
Yes, and this is the single most common cause of "the signature never verifies" tickets. JSON is not canonical: {"a":1,"b":2} and {"b":2,"a":1} carry the same data and hash to different values, and so do two documents that differ only in whitespace or in how a float is rendered. Parsing and re-serialising before verification changes the bytes. In Express that means mounting a raw body parser on the webhook route before express.json() gets to it; in ASP.NET it means buffering the request body and reading it before model binding.
Versioning. A header carrying t=1756547412,v1=3d5a... can grow a v2 alongside v1 when the scheme changes, and receivers parse the comma-separated list and verify the versions they understand. Without a version, changing the algorithm or the signed string means a flag day where every receiver breaks at once. Parse the list; do not assume the field you want is first.
It depends on whether the sender supports two active secrets. Where it does, add the new secret, accept either during an overlap window, then retire the old one. The Pinlyx Data API takes the other trade-off deliberately: a webhook endpoint secret is returned exactly once, at creation, and is never readable again, so rotation means deleting the endpoint and registering it afresh. That costs you a maintenance window and buys a much smaller blast radius, because there is no second place the secret is ever stored or displayed.
Ready to ship

Signed on the way out. Verifiable on the way in.

Per-destination secrets, a timestamp inside every signed payload, a signed verification probe before the first real event, and a stable event id for de-duplication.

Free forever plan · GDPR-ready · No credit card required

We value your privacy

We use cookies to improve our site, analyze traffic, and personalize ads. You can accept all, reject non-essential, or customize your choices. Read our Cookie Policy.