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
His 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.ipadis the byte0x36repeated to the block size, andopadis0x5Crepeated. 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), orsubtle.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.