HMAC & webhook signing
Proving a message came from who it claims to - and wasn't tampered with in transit
Why HMAC?
The standard case: you expose an API. You want to allow certain callers in and reject everything else. For each caller you trust, you generate a secret and share it with them out-of-band - through a dashboard, an onboarding email, a secrets manager. Every request they send to your API, they sign with that secret. Your server verifies the signature on every incoming request - if it checks out, the request is genuine and unmodified; if it doesn't, you reject it before doing anything else.
Each caller gets their own unique secret. This matters: if one secret is compromised you revoke just that one, without affecting any other caller. It also lets you attribute requests - you know which caller sent which request, because only they hold that secret.
Webhooks flip the direction - now you are the callee, receiving requests from a provider like Stripe or GitHub rather than sending them. The same mechanism applies in reverse: they sign each payload with a secret they gave you, and you verify it on receipt.
What is HMAC?
HMAC (Hash-based Message Authentication Code) is a way to produce a short, fixed-length tag for a message using a secret key and a hash function. Anyone who holds the same key can recompute the tag and verify it matches. Anyone without the key cannot forge a valid tag - even if they know the algorithm and can see the message.
It gives you two things at once:
- Integrity - if the message was altered in transit, the tag won't match.
- Authenticity - if the tag matches, it was produced by someone who holds the secret key.
HMAC is not encryption. The message itself is still readable by anyone who sees it. HMAC only proves the message came from someone holding the secret key, and that it wasn't altered in transit. If you need the contents to be private, that is a separate concern - handled by TLS at the transport layer, or by encrypting the payload itself.
How it works
HMAC wraps a standard hash function (SHA-256 is the most common choice today) in a construction that mixes in the secret key at two points:
HMAC(key, message) = H( (key XOR opad) || H((key XOR ipad) || message) ) # H = hash function (e.g. SHA-256) # || = concatenation (join two byte strings end to end, not logical OR) # opad = outer padding: 0x5c repeated # ipad = inner padding: 0x36 repeated
You don't need to remember that construction - libraries handle it. The important point is why it's structured this way: a naive attempt like H(key || message) is vulnerable to a length extension attack, where an attacker who knows the hash output can append data and produce a valid hash for the extended message without knowing the key. The double-hashing in HMAC closes that off.
The output is a fixed-length string - 32 bytes for HMAC-SHA256 - that looks like:
HMAC-SHA256("secret-key", "hello world")
→ b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576dea4...Change one byte of the message, or use a different key, and the output is completely different - there is no correlation between the inputs and the output that an attacker can exploit.
Webhook signing
The most common place a web developer encounters HMAC is webhook verification. When a service like Stripe, GitHub, or Shopify sends a webhook to your server, how do you know the request actually came from them and not from an attacker who knows your endpoint URL?
The answer: the provider signs every request body with a shared secret, and you verify the signature before doing anything with the payload.
The flow from the provider's side:
- An event occurs (e.g. a payment completes).
- They serialise the event to JSON.
- They compute
HMAC-SHA256(your_webhook_secret, json_body). - They send the JSON body in the request, plus the signature in a header (e.g.
Stripe-Signature,X-Hub-Signature-256).
Your job on receipt: recompute the HMAC over the raw body using the same secret, and compare it to the signature in the header. Match means the request is genuine and unmodified. No match means reject it.
Why not just rely on TLS?
Your webhook endpoint absolutely should use TLS - and in practice it will, because any internet-facing server should. But TLS and HMAC are solving different problems. They are not alternatives; you need both.
TLS operates at the transport layer. It encrypts the connection and proves you are talking to the right server - the one whose domain matches the certificate. What it does not do is say anything about who sent the request. Once a connection is established to your server, TLS has done its job. Anyone who knows your webhook URL can open a TLS connection and POST whatever they like to it. The URL is often not secret - it might appear in logs, config files, or error messages.
HMAC answers the question TLS cannot: did the entity holding the shared secret produce this payload? TLS guarantees the bytes arrived unmodified from whoever sent them - but it says nothing about who that was. Anyone who knows your endpoint URL can open a TLS connection and POST whatever they like. The URL is often not secret. HMAC is what proves the sender was specifically the party you shared a secret with.
TLS integrity also only lasts for the life of the connection. If a payload is logged, queued, or forwarded between services after receipt, TLS is long gone. An HMAC signature travels with the payload and can be verified at any point along the way.
What each gives you
- TLS - encrypted transport, server identity, integrity of bytes in transit.
- HMAC - proof that a specific party (the one holding your secret) produced this exact payload, verifiable at any point regardless of how the payload was delivered.
TLS authenticates the server to the client. HMAC authenticates the payload to the receiver. They operate at different layers and cover different attack surfaces.
Verifying in practice
Here's how you verify a GitHub webhook in Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyGithubWebhook(
rawBody: Buffer,
signature: string, // value of X-Hub-Signature-256 header
secret: string,
): boolean {
const expected = "sha256=" + createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}A few things worth noting in that snippet:
- The body is the raw bytes, not a parsed object. Parse the JSON after verification, never before. If your framework has already parsed the body, it may have changed whitespace or key ordering - the signature will fail.
- The comparison uses
timingSafeEqual, not===. More on this below. - GitHub prepends
sha256=to the signature. Strip or account for that prefix - different providers use different formats.
The same pattern in Python:
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)hmac.compare_digest is Python's equivalent of timingSafeEqual. Both exist for the same reason.
Why you must use a constant-time comparison
A normal string comparison (===, ==) returns false as soon as it finds a mismatched byte. This means it finishes faster for inputs that differ early. An attacker who can send many requests and observe response times can exploit this: they iterate through possible signature values one byte at a time, and the one that takes slightly longer to reject is the correct first byte. Repeat for each byte, and the signature is recoverable without the secret.
This is a timing attack. It sounds exotic but has been demonstrated against real production systems over the public internet.
timingSafeEqual / compare_digest compares every byte regardless of where the first mismatch occurs. The comparison always takes the same amount of time. Use it whenever comparing a secret, token, or MAC. Never use string equality.
Common mistakes
- Parsing before verifying. If you pass the body through
JSON.parse(or let Express/FastAPI do it) before computing the HMAC, you're computing over something different from what the sender signed. Always verify first on the raw body bytes. - Using string equality. As above - use a constant-time comparison.
- Not rejecting missing signatures. If the header is absent, the correct response is a 400 rejection, not falling back to "trust the payload anyway."
- Logging the secret. The webhook secret should be treated like a password. It goes in an environment variable, not in code, and never in logs.
- Skipping replay protection.HMAC only verifies authenticity and integrity - it does not prevent an attacker from re-sending a previously captured, valid request. Stripe's signature header includes a timestamp for this reason; reject requests where the timestamp is more than a few minutes old.
Replay protection
Stripe includes a timestamp in the signed payload to protect against replay attacks. Their Stripe-Signature header looks like:
Stripe-Signature: t=1714000000,v1=abc123...
To verify it, Stripe instructs you to:
- Extract the timestamp
tand signaturev1. - Construct the signed payload:
t + "." + raw_body. - Compute
HMAC-SHA256(secret, signed_payload)and compare tov1. - Check that
tis within 300 seconds of the current time.
If an attacker captures a valid Stripe request and replays it five minutes later, step 4 rejects it. The timestamp is part of the signed content, so it cannot be modified - any change would invalidate the signature. This is a good pattern to follow if you are building your own webhook-sending system.
If you're building the sending side
If your system sends webhooks to other people's servers, the same HMAC pattern applies in reverse:
- Generate a random 32-byte secret per customer endpoint. Give it to them at registration time.
- For each event, sign the serialised payload with their secret and include the signature in a header.
- Include a timestamp in the signed payload to enable replay protection.
- Document the verification process clearly - make it easy for them to implement correctly.
Use a header name that makes the algorithm explicit - X-Signature-SHA256 or a vendor-namespaced equivalent. Avoid vague names like X-Signature that leave the algorithm ambiguous.