Verify Webhook Signature with HMAC-SHA256
When a service like Stripe, GitHub, or Shopify calls your webhook endpoint, anyone who knows the URL can POST to it too. The signature header is how you prove the request actually came from the provider and was not tampered with. Verification means recomputing an HMAC-SHA256 digest of the request body with your shared secret and checking that it matches the value the provider sent in a header. If you only want the concept behind this, read What Is a Webhook Signature? first. This guide is the implementation recipe: the exact bytes each provider signs, the headers to parse, and the mistakes that silently break verification.
The three rules that catch everyone
Before any provider-specific code, internalize these. They cause the overwhelming majority of "valid signature rejected" and "everything passes even with the wrong secret" bugs.
- Sign the raw body, never the re-serialized JSON. If your framework parses the body to JSON and you stringify it back, the bytes change. Key order, whitespace, and Unicode escaping all differ from what the provider signed, so the HMAC will never match. You must capture the raw request bytes exactly as received and feed those to HMAC.
- Match the encoding the header uses. An HMAC-SHA256 digest is 32 raw bytes. Stripe and GitHub send it as a 64-character lowercase hex string; some providers use Base64 instead. Compute your digest in the same encoding before comparing, or compare the raw 32 bytes on both sides.
- Compare in constant time. A normal string equality check returns as soon as it finds a differing character, leaking timing information that can help an attacker forge a signature. Use a constant-time comparison:
crypto.timingSafeEqualin Node orhmac.compare_digestin Python.
Stripe: signed payload is timestamp + "." + raw body
Per Stripe's documentation, the Stripe-Signature header contains comma-separated key/value pairs: a timestamp as t= and one or more signatures as v1= (the scheme using your endpoint's webhook signing secret, which starts with whsec_). The signed payload is not the body alone. It is the timestamp, a literal period, and the raw body concatenated together.
const crypto = require('crypto');
function verifyStripe(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=').map(s => s.trim()))
);
const timestamp = parts.t;
const signed = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signed, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
const sigOk = a.length === b.length && crypto.timingSafeEqual(a, b);
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
const fresh = age <= toleranceSec;
return sigOk && fresh;
}
The timestamp is not decoration. Because it is part of the signed payload, an attacker cannot alter it without invalidating the signature. Including it in the signed payload and rejecting anything older than your tolerance (Stripe's libraries default to 300 seconds) is what stops a replay attack, where an attacker captures one valid request and resends it later. Always check both the signature and the age, and keep your server clock synced via NTP so a clock skew larger than the tolerance does not reject legitimate events.
GitHub: HMAC-SHA256 hex over the raw body, sha256= prefix
GitHub signs each webhook with the secret you configured and sends the result in the X-Hub-Signature-256 header, documented as the string sha256= followed by the lowercase hex digest. (The older X-Hub-Signature header uses SHA-1 and exists only for legacy clients; prefer the SHA-256 header.) There is no timestamp folded into the payload here; you HMAC the raw body directly.
import hmac, hashlib
def verify_github(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header or "")
Note raw_body is bytes, taken straight from the request, not a parsed-and-re-dumped dict. In Flask that means request.get_data() before any JSON access; in Express, configure the body parser to retain the raw buffer (for example via the verify callback on express.json) rather than reading req.body after parsing. GitHub also recommends handling the payload as UTF-8, and making sure no proxy or load balancer rewrites the body or headers before you verify.
Shopify and the Base64 variant
Shopify follows the same HMAC-SHA256-over-raw-body idea but, per its documentation, sends the digest Base64-encoded in the X-Shopify-Hmac-Sha256 header rather than as hex, and the key is your app's client secret (the API secret key), not a separate webhook signing secret. HMAC verification applies to HTTPS deliveries only. The only thing that changes versus GitHub is the output encoding: call .digest('base64') in Node or base64.b64encode(...) in Python, then constant-time compare against the header value. This is exactly the hex-versus-Base64 gotcha: identical algorithm, identical secret, different string representation, so a hex comparison against a Base64 header always fails even when the request is genuine. Note that after you rotate the client secret, Shopify can take up to an hour to switch to signing with the new one.
Confirm your digest by hand
When verification keeps failing and you cannot tell whether the bug is your raw-body capture, your secret, or your encoding, take the provider out of the loop and reproduce the digest manually. Paste the exact raw body and your signing secret into the HMAC generator, choose SHA-256, and read off the hex output. For Stripe, prepend the timestamp and period to the body first. If that value equals the v1= or sha256= portion of the header, your secret and payload are correct and the bug is in how your server reads the body. If it does not match, your raw body or secret is wrong.
The same approach helps when you are debugging plain hashing rather than keyed HMAC: the hash generator shows SHA-256 and other digests of arbitrary input, and the checksum verifier confirms whether two digests are equal so you do not have to eyeball 64 hex characters. Everything runs in the browser, so a test payload and secret never leave your machine.
A verification checklist
- Capture the raw request body as bytes or a string before any JSON parsing.
- Build the exact signed payload the provider specifies (raw body for GitHub and Shopify;
timestamp.bodyfor Stripe). - Compute HMAC-SHA256 with the correct secret and the documented output encoding (hex or Base64).
- Parse the signature out of the header, stripping any prefix or key like
sha256=orv1=. - Compare in constant time, and for Stripe also reject stale timestamps to block replays.
- Return 401 on mismatch and only then process the event. Verify before you trust the payload.
Frequently Asked Questions
Almost always because you are signing re-serialized JSON instead of the raw body. Parsing the request to an object and stringifying it back changes whitespace, key order, and Unicode escaping, so the bytes no longer match what the provider signed. Capture and HMAC the raw request body exactly as received.
It depends entirely on the provider's header format. Stripe and GitHub send a lowercase hex string, so compute your digest as hex. Shopify sends Base64. The algorithm and secret are identical; only the output encoding differs. Comparing a hex digest against a Base64 header will always fail even on genuine requests.
A normal equality check stops at the first differing byte, so its runtime leaks how many leading bytes matched. An attacker can exploit that timing to guess a valid signature byte by byte. Use crypto.timingSafeEqual in Node or hmac.compare_digest in Python, which take the same time regardless of where the inputs differ.
To prevent replay attacks. Stripe signs the timestamp plus a period plus the raw body, and its libraries reject requests whose timestamp is older than a tolerance (300 seconds by default). Because the timestamp is part of the signed payload, an attacker cannot alter it without breaking the signature, and without the freshness check a captured request could be resent indefinitely.
In Flask, call request.get_data() before touching request.json so you read the unparsed bytes. In Express, the JSON body parser consumes the stream, so configure it to keep the raw buffer, for example with the verify callback on express.json that stashes the raw bytes on the request for later HMAC computation.