Skip to content
Back to Blog
Security

Webhook Signature Verification Failed: Causes and Fixes

Webhook signature verification failed? Usually it's the raw body, the digest encoding, or a missing timestamp prefix. Debug yours with a free HMAC tool.

15 min read

Webhook Signature Verification Failed? Find Your Cause

A webhook signature verification failed error means one thing: the digest your code computed does not equal the digest in the request header. That is the entire message. It says nothing about permissions or expiry, and it is almost never a bug in the provider’s SDK. Something differs between the bytes the provider hashed and the bytes you hashed.

Four inputs decide the outcome: which bytes were signed, which key bytes were used, which hash algorithm ran, and which text encoding you compared in. Get any one wrong and the failure looks identical. The error carries no hint about which one it was, so the job is narrowing the input space rather than reading the message more carefully.

Pick a starting branch:

Signature doesn't match? Three branches:
├─ Did your framework parse the JSON before you saw it?        → Section 3
├─ Does the header value carry a prefix, or look like base64?  → Section 4
└─ Does the provider's header contain a timestamp?             → Section 2

1. What a signature mismatch tells you

Verification is a comparison of two byte strings. When it fails, one of four things is wrong, and they are independent of each other.

Which bytes got signed. The provider hashed a specific sequence of bytes. Maybe that is the request body alone, maybe it is a timestamp glued to the front of the body. If your framework parsed the JSON and handed you an object, you no longer have those bytes and cannot reconstruct them reliably. This is Section 3, and it is the most common cause by a wide margin.

Which key bytes got used. The same secret string can be interpreted as UTF-8 text, as hex, or as base64, and each reading produces a different key. So does a secret with an extra newline the config loader kept. A second failure hides in this dimension: the secret may be the wrong secret entirely rather than the wrong reading of the right one, which is Section 6.

Which encoding you compared in. A digest is 32 raw bytes for SHA-256. Hex and base64 are two ways of writing those same bytes down as text, and they never look alike. Compare one against the other and you get a permanent hmac signature mismatch even though the underlying bytes agree.

Which hash algorithm ran. Most providers use SHA-256 and document it, so this dimension usually costs you nothing. GitHub is the exception worth knowing about: every delivery carries X-Hub-Signature (HMAC-SHA1) next to X-Hub-Signature-256 (HMAC-SHA256), and GitHub’s own docs say the SHA-1 header “is only included for legacy purposes” while recommending the 256 variant. Read the wrong one and the length gives it away before the bytes do. The body from Section 2, signed with the same secret under SHA-1, is sha1=ba2954d180839d8170b08b32cd38483775aaae96 — 40 hex characters against the 64 of its SHA-256 digest.

Keep those four separated while you debug. The fastest way to isolate a dimension is to compute the digest outside your application from inputs you control: paste a body and a secret into the HMAC generator and see what you get. It runs entirely in your browser and the secret never leaves the page, so a production signing secret is safe to paste into it. HMAC runs the same SHA-256 primitive as a plain SHA-256 hash, just keyed with your secret, so if you can reproduce the provider’s value by hand, the cryptography is fine and the bug is in your request handling.

2. What the four big providers actually sign

The assumption that sinks most integrations is that every provider signs the request body and nothing else. Two of the four biggest do not. What each one hashes, verified against the current provider documentation:

ProviderHeaderSigned stringEncodingValue prefixSecretTimestamp tolerance
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…endpoint signing secret (whsec_ prefix)5 minutes (300 seconds)
GitHubX-Hub-Signature-256rawBody (no prefix)hexsha256=webhook secret tokennone (no timestamp sent)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=signing secret5 minutes
ShopifyX-Shopify-Hmac-SHA256rawBodybase64noneapp client secret (not a separate webhook secret)none

Those four happen to cover three orthogonal axes. The signed string is either the body alone or a timestamp concatenation, and even the separator differs: Stripe uses . while Slack uses :. The encoding is hex for three and base64 for one. The secret comes from a dedicated webhook credential for three, and from the app’s client secret for Shopify, which is the detail people get wrong most often because there is a field labelled “webhook” in the admin UI that is not the thing you want.

The same body signed four ways with one secret:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
ShapeValue
GitHub stylesha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Shopify styleCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Stripe stylet=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Slack stylev0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Read the first two rows together, because they are the same 32-byte digest written twice. Sixty-four hex characters, or forty-four base64 characters including padding. Nothing about the two strings suggests they are equal, which is why comparing across encodings produces a mismatch that survives every “but the secret is right” check you can think of.

The last two rows prove the other half of the point. Same body, same secret, same algorithm, and neither digest resembles the GitHub one, because the string being hashed now starts with a timestamp. Most reports of a Stripe webhook signature verification failed error come down to this row: the code hashed the body on its own and never prepended the t value and the dot. Reproduce all four in the HMAC generator by editing only the message field and switching the output format, and the mechanism stops being abstract.

One practical consequence of the timestamp column: a Stripe or Slack digest is only valid for a few minutes, so you cannot capture a signature today and replay it in a test tomorrow. GitHub and Shopify signatures are stable forever, which makes them far easier to debug and also means you have to think about replay protection yourself.

3. The raw body problem

Most reports of webhook signature verification failed trace back to a framework that read and parsed the body before your handler ever saw it.

Your framework already destroyed the bytes

Web frameworks are built to save you from parsing. That convenience is what breaks signature verification, because by the time your handler runs, the original bytes are gone.

express.json() reads the request stream, parses it, and replaces req.body with a JavaScript object. The stream is consumed and cannot be read again. In FastAPI, declaring a Pydantic model or a dict body parameter means the framework reads and parses before your function is entered. Rails populates params from the JSON body through a middleware that runs before your controller action. Spring’s Jackson converter turns the body into your DTO class, and by default the underlying HttpServletRequest input stream can only be read once.

Nothing here is a bug. Every one of these is doing what it was configured to do. The problem is that a signature covers bytes, an object is not bytes, and turning the object back into bytes is a different operation from the one the provider performed.

Why re-serializing sometimes works, and that’s the trap

The usual advice is that re-serializing changes the bytes. That is incomplete, and the missing half is what makes this failure so hard to diagnose. Sometimes it changes nothing at all.

JSON.stringify(JSON.parse(body)) === body, measured across payload shapes:

Payload shapeBytes after round-tripChange
{"id":42,"event":"user.created"}identicalnone, which is why local tests pass
{"amount":1.0}changed{"amount":1}
{"n":1e3}changed{"n":1000}
{"id":12345678901234567890}changed{"id":12345678901234567000} (precision lost)
{"name":"caf\u00e9"}changed{"name":"café"} (6 bytes become 2)
{"a":1}\nchangedtrailing newline swallowed
{ "a" : 1 }changedinterior whitespace swallowed
{"v":-0.0}changed{"v":0}
{"p":0.1000000000000000055511151231257827}changed{"p":0.1}

Look at the first row. A flat object with an integer and a short ASCII string round-trips byte for byte, so a parse-then-restringify verifier passes every test you wrote against a fixture like that. Then you deploy, and the first payload carrying a monetary amount of 1.0, an ID beyond 2^53, or a customer name with an accent fails. Not all of them. Just those.

That is the mechanism behind “works locally, intermittent 401 in production”, and it is considerably worse than a verifier that fails all the time. A verifier that always fails gets fixed in an hour. One that fails on 3% of events gets blamed on the provider, retried, escalated, and lived with for weeks. If your failure rate is somewhere strictly between zero and one hundred percent, this table is where to look first.

Key order is the cause people expect and the least likely one in practice, because JSON.parse preserves insertion order for string keys. Numbers and whitespace are the real culprits.

Getting the raw body in each framework

Express, with the route-specific parser registered before the global JSON parser:

const express = require('express');
const crypto = require('crypto');
const app = express();

// This route must be registered BEFORE app.use(express.json()).
// body-parser marks the request as parsed, so a later raw() silently yields {}.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // a Buffer, not an object
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // hash the Buffer directly, no toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // every other route still gets parsed JSON
app.listen(3000);

If you cannot reorder middleware, keep a copy during parsing instead:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI. Starlette caches the body, so await request.body() returns the original bytes even in a handler that also receives a parsed model:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # bytes, exactly as received
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, where request.raw_post gives you the unparsed body as a string:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go, where you read the body yourself and must remember it is drained afterwards:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Unmarshal from raw, never from r.Body — it has no bytes left.
	w.WriteHeader(http.StatusOK)
}

Spring, where asking for byte[] skips Jackson entirely:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper is the alternative when a filter has to do the check and you cannot change the controller signature. It has a trap of its own: getContentAsByteArray() returns bytes only after something downstream has read the stream, so calling it before chain.doFilter(...) gives you an empty array.

4. Encoding mismatches: hex, base64, and the key itself

Three separate encoding decisions sit between your digest and the header value, and any of them can break the comparison on its own.

The digest encoding. HMAC-SHA256 output is 32 bytes. Written as lowercase hex it is 64 characters; written as standard base64 it is 44 including the = pad. The two rows from Section 2 are one digest written in both:

EncodingCharactersSame 32 bytes written as
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

A quick heuristic when you are staring at an unfamiliar header: if the value is 64 characters of 0-9a-f, it is hex. If it is 44 characters ending in =, or contains +, /, or uppercase letters, it is base64. When you want to confirm rather than guess, run the base64 value through the Base64 decoder and check that it yields 32 bytes; if it does, both strings describe the same digest and you were comparing text formats, not signatures.

The value prefix. GitHub sends sha256= in front of the hex. Slack sends v0=. Stripe wraps everything in a comma-separated list of key=value pairs. None of those characters are part of the digest, so either strip the prefix from the header or add it to your own value. Doing neither is the most common reason an otherwise correct implementation reports an hmac signature mismatch, and in Node it does not even report a mismatch, as Section 7 explains.

The key encoding. The secret is bytes too, and the same string read as UTF-8, hex, or base64 gives three different keys. Providers that hand you a text token like whsec_... want UTF-8, but plenty of internal systems distribute base64 or hex secrets that must be decoded before signing. This failure mode is identical in shape to the JWT version of the problem and is covered in depth in JWT invalid signature: every cause and how to fix it, including how to tell whether a given secret is base64 or plain text.

5. Timestamp, tolerance, and replay windows

You can compute a digest that matches perfectly and still be rejected. Providers that include a timestamp expect you to check it, and a stale timestamp is a valid signature you must refuse anyway.

ProviderWhere the timestamp livesWindow
Stripet= inside Stripe-Signature5 minutes (300 seconds)
SlackX-Slack-Request-Timestamp header5 minutes
GitHubnot sentnot applicable
Shopifynot sentnot applicable

Both directions of getting the window wrong hurt. Too generous, and a captured request stays replayable for as long as you allow, which defeats most of the point of checking the timestamp. Too tight, and ordinary clock drift starts rejecting real deliveries. Five minutes is what both providers chose, and copying that is a sound default.

Before you widen a tolerance, check the clock. Container images do not run NTP, and a VM resumed from a snapshot can be minutes behind wall time with nothing in the logs to say so. A host that drifts steadily produces failures that begin as occasional and become total, which reads like a code regression and is not one.

The other clock bug is a unit mismatch. Every provider in the table sends epoch seconds. Compare one against a millisecond value like JavaScript’s Date.now() and the difference is roughly a thousand times the real age, so every event is outside every plausible window. The symptom is a tolerance check that rejects one hundred percent of deliveries while the digest itself matches. If you are unsure which unit you are holding, the length is the tell, and epoch seconds versus milliseconds covers the conversions and the timezone traps around them.

Use the raw timestamp string from the header when you build the signed string, not a parsed and reformatted number. Parsing 1700000000 to a float and printing it back can yield 1700000000.0, and that is a different byte sequence.

6. Wrong secret, and secrets that rotate

Before you go any further into encodings, rule out the plainest cause: the secret may not be the right secret. Stripe’s docs are explicit that “Stripe generates a unique secret key for each endpoint,” and that if you point the same URL at both test and live keys, “the secret is different for each one.” Three versions of one mistake follow from that.

Test mode and live mode hold separate secrets, so a value copied while the dashboard was in test mode fails every live delivery. Each endpoint holds its own, and the docs add that “if you use multiple endpoints, you must obtain a secret for each one you want to verify signatures on” — aim two endpoints at one handler with one secret in the environment and half your traffic fails. And stripe listen prints a signing secret for the CLI’s local forwarding, which is a separate endpoint from anything registered in the dashboard, so the two are not interchangeable.

None of these look like encoding bugs from the outside. The digest is well formed, the comparison is correct, and the value in your environment is a real Stripe secret — just not the one that signed this delivery.

Rotation is the same dimension moving under you. It looks least like an encoding problem and gets misdiagnosed as a code bug most often. Nothing in your code changed, verification worked yesterday, and now a fraction of events fail.

The overlap window is deliberate. Stripe keeps the old endpoint secret valid for up to 24 hours after you rotate, and during that period the Stripe-Signature header carries one v1 signature for each active secret. Shopify goes the other way: after rotation it can take up to an hour before it starts using the new secret to compute digests, so the old one is what you need in the meantime.

The Stripe behaviour is what breaks code, because the header looks like it has one signature in it. Splitting on , and taking the first v1 you find works right up until there are two, at which point you match roughly half the time depending on which secret signed which event. Iterate over all of them:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // keep the original string
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Two details in there matter beyond the loop. The timestamp goes into the signed payload as the string it arrived as, and the body is concatenated as bytes rather than through template interpolation, which would decode it as UTF-8 first.

The same shape applies when you rotate on your side: accept both the old and the new secret for the length of the overlap, then drop the old one. Whatever you rotate to needs full entropy, so generate it rather than typing it, using something like the signing secret generator for a 256-bit random value.

7. Comparing signatures without leaking timing

Once you have two digests, how you compare them is a security decision. String equality returns as soon as it finds a differing byte, so the time it takes reveals how many leading bytes were correct. An attacker who can submit many requests uses that to recover a valid signature one byte at a time. It is slow and noisy over the internet, and entirely practical on a local network.

Every runtime ships a fixed-time comparison:

LanguageConstant-time compareWhen lengths differ
Nodecrypto.timingSafeEqual(a, b)throws
Pythonhmac.compare_digest(a, b)returns False
Gohmac.Equal(a, b)returns false
PHPhash_equals($known, $user)returns false
RubyOpenSSL.secure_compare(a, b)returns false

That last column is where a whole class of confusing incidents comes from. Node is the outlier, and it does not fail politely:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

It fires on a trivial slip. A hex SHA-256 digest is 64 characters. The value in X-Hub-Signature-256 is 71, because sha256= is seven characters. Forget to strip the prefix and the two buffers have different lengths, so timingSafeEqual throws instead of returning false. Uncaught, that exception propagates out of your handler and Express turns it into a 500.

From the outside that looks like something else entirely. You are looking for a webhook 401 unauthorized response, you get a server error, so you go read your handler and your event dispatcher. The bug is one line above the comparison. Comparing a 64-character hex digest against a 44-character base64 one throws for the same reason, which means an encoding mismatch in Node also surfaces as a 500 rather than a clean rejection.

The fix is to check the length yourself and return false:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // guard before the call
  return crypto.timingSafeEqual(a, b);
}

Leaking the length is harmless; a digest length is fixed by the algorithm and public. What you must not leak is which prefix matched. The Verify tab of the HMAC generator folds the length difference into the same constant-time accumulator instead of returning early, so a length mismatch comes back as a plain false rather than an exception, and you can check a header value against your computed digest without writing throwaway code.

8. When the transport layer changed your bytes

You have ruled out the signed string, the raw body, the encodings, the clock, and rotation. What is left is the possibility that the bytes arriving at your process are not the bytes that left the provider.

Compression. A provider or proxy may send the body gzipped with Content-Encoding: gzip. The signature covers the uncompressed payload, so you must hash after decompression. Some frameworks decompress transparently and some hand you the compressed bytes, and a body that looks like binary garbage in your log is the giveaway.

Chunked transfer. With Transfer-Encoding: chunked there is no Content-Length, and code that trusts that header to size a read buffer truncates the body. The digest of a truncated body is valid nonsense: it will never match, and nothing looks wrong.

Proxies and WAFs. Any layer that reads and rewrites the body can change it. AWS API Gateway can base64-encode the body before it reaches a Lambda, so you must decode before hashing. Application load balancers, service meshes, and web application firewalls can all normalize or re-encode a payload on the way through. Test by comparing the byte length your handler sees against the Content-Length the provider sent.

Character encoding and BOM. Payloads can contain non-ASCII characters, and GitHub’s documentation is explicit that the payload must be handled as UTF-8. Decoding the body to a string in the wrong charset and re-encoding it destroys every multi-byte character. A UTF-8 byte order mark, EF BB BF, prepended by a well-meaning editor or serializer adds three bytes that were never signed.

Line endings and stray whitespace. A body that crossed a text-mode file boundary can arrive with LF rewritten to CRLF. Read the provider’s spec for the exact signing string too: some append a character of their own, and Typeform documents a trailing newline as part of what gets hashed. When a provider’s docs mention any extra character, take it literally.

9. A repeatable debugging workflow

Run these in order. Each step either finds the bug or eliminates a branch, and stopping early is the point.

  1. Log the raw bytes before any middleware runs. Write the body to a file, or log its byte length plus its SHA-256, from the earliest point in the request lifecycle you can reach. Length alone resolves a surprising number of cases: a value one greater than expected is a trailing newline, three greater is a BOM.
  2. Compute the digest by hand. Paste those exact bytes and your secret into the HMAC generator, pick SHA-256, and set the output format to match the header. Doing this before anything else splits the problem cleanly in two.
  3. Compare the hand-computed value with the header. Equal means the bytes and the secret are both correct and the bug is somewhere in your code path, so go read your comparison. Not equal means one of the inputs is wrong, so continue.
  4. Check the signed string against the table in Section 2. Does this provider prepend a timestamp? With which separator? Add the prefix in the tool and recompute.
  5. Switch the digest encoding. Recompute as hex and as base64 and compare both against the header. A 44-character header value with an = on the end is base64, whatever your code assumed.
  6. Switch the key encoding. Try the secret as text, then hex, then base64. One of the three usually produces a match, and that tells you what the provider expects.
  7. Check the clock and the rotation state. Compare your server’s time against a known source, confirm you are handling epoch seconds, and check the provider’s dashboard for a rotation in the last 24 hours.

Two habits make this loop much faster. First, capture one failing payload and work from it offline instead of waiting for the next delivery. Second, replay that captured body against your endpoint with a fixed signature so the input never varies between attempts. The cURL command builder assembles the request with the exact headers and a body read from a file, which keeps the bytes stable across runs. Reproducing the failure on demand is what turns an intermittent webhook signature verification failed report into a five-minute fix.

If you still need to file a support ticket, include the byte length of the body you hashed, the header value verbatim, the signed string construction you used, and the digest encoding. Never include the secret itself.

FAQ

Why does my webhook signature work locally but fail in production?

Your test payload probably survives a JSON round-trip unchanged, so re-serializing it is harmless. Real payloads contain floats, large integers, Unicode escapes, or extra whitespace, and those do change the bytes. Sign the raw body instead of a re-serialized copy; the table in Section 3 shows which shapes break.

Should I include the sha256= prefix when comparing signatures?

Strip it, or add it to your own value so both strings match exactly. Your computed hex digest is 64 characters and the header value is 71 with the prefix. Some comparison functions return false on a length mismatch, and Node’s timingSafeEqual throws instead of returning false.

Can I verify the signature after my framework parsed the JSON?

Not reliably. Re-serializing reproduces the original bytes only for payloads with no floats, no integers beyond 2^53, no Unicode escapes, and no extra whitespace. The moment one appears the digest changes, so verification passes in testing and fails on a fraction of production events.

Why do Stripe and GitHub produce different signatures for the same payload?

Because they hash different strings. GitHub signs the raw body alone. Stripe signs the timestamp, a literal ., then the body, so one payload delivered at two different times yields two different digests. Slack prepends v0: and its own timestamp. Same algorithm, different input.

How long should the timestamp tolerance be?

Five minutes is what Stripe and Slack use, and copying it is a reasonable default. Shorter windows reject legitimate deliveries as soon as your server clock drifts. Longer windows widen the period in which a captured request can be replayed. Sync clocks with NTP before loosening the tolerance.

Does timingSafeEqual return false when the lengths differ?

No. Node throws RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Uncaught, that becomes a 500 instead of a 401, which sends you debugging your handler rather than the line above the comparison. Compare lengths first and return false yourself.

My provider rotated the secret, so why do some webhooks still fail?

Rotation windows overlap. Stripe keeps the old secret valid for up to 24 hours and sends one v1 signature per active secret, so code that reads only the first v1 fails on roughly half the events. Shopify can take up to an hour to start using the new secret.

Conclusion

Verification is a byte comparison, so webhook signature verification failed always resolves to a disagreement about bytes rather than to anything cryptographic. Keep the three dimensions apart while you debug. Capture the raw body before any parser touches it, and never hash a re-serialized object: it matches often enough to pass your tests and not often enough to work in production. Read the secret the way the provider reads it, since text, hex and base64 readings of one string give three different keys. Then check the encoding you compared in, because hex is 64 characters and base64 is 44 and both can describe the same 32 bytes.

After those, in rough order of likelihood: the timestamp prefix, the value prefix, the tolerance window, the rotation overlap, and the transport layer. However you write the comparison, guard the length first, then hand both values to your runtime’s constant-time function.

When you want a value you can trust to compare against, compute it outside your application: paste the body and the secret into the HMAC generator and let it tell you which side is wrong.

Tags: webhook hmac api-security debugging authentication

Related Articles

View all articles