openlang

HMAC mismatch on one webhook only (the one with accents in the name)

I wrote a small Node checker (plain JS, no build step) that replays saved webhook deliveries and verifies the HMAC-SHA256 before anything downstream trusts them. Two of three pass. evt_2044 from billing-partner fails, but evt_2051 from the same partner passes with the same secret.

$ node main.js
staging         evt_1001  ok
billing-partner evt_2044  FAIL (digest)
billing-partner evt_2051  ok

Both secrets were rotated last week, so I first blamed a stale secret. But then evt_2051 wouldn't have passed either. The only difference I can see is the customer name in 2044: Zoë Ramírez, with accents.

So I suspected utf8 vs latin1 somewhere in how the payload goes into createHmac. I tried changing the encoding passed to update(). No difference at all.

The core of lib/verify.js:

const body = JSON.parse(delivery.raw);

// sign the payload, same as the partner docs describe
const payload = JSON.stringify(body);
const expected = crypto
  .createHmac("sha256", secret)
  .update(payload, "utf8")
  .digest("hex");

The partner docs say the signature is over "the JSON payload" (I'm fairly sure), so I don't see what else I should be hashing. What am I missing? And why only that one event?

(The toleranceSeconds replay check is a TODO, ignore it.)

The conversation continues 7

Follow the reasoning
  1. Permalink

    I'd bet the problem is the JSON.parse → JSON.stringify round trip, not the encoding passed to update(). That's why changing it did nothing.

    HMAC is over the exact bytes the partner signed. Once you parse and re-stringify, you're signing your serialization of the data, not theirs. For plain ASCII payloads with compact formatting, the two often come out byte-identical, so evt_2051 and evt_1001 pass by luck. evt_2044 has non-ASCII characters, and that's where serializers diverge. If the partner emits Zo\u00eb Ram\u00edrez (escaped, as some Java/PHP/Python encoders do by default), JSON.stringify will give you the literal Zoë Ramírez instead, and the digest changes. It can go the other way too. Key order, whitespace and number formatting can also differ, but the accents fit your symptom best.

    Fix: hash delivery.raw directly, untouched.

    const expected = crypto
      .createHmac("sha256", secret)
      .update(delivery.raw)   // string or Buffer, exactly as received
      .digest("hex");

    Only call JSON.parse after the signature checks out. If you saved the deliveries to disk, also make sure whatever wrote them didn't re-encode or normalize the text. Storing the raw Buffer is safest.

    While you're in there, compare digests with crypto.timingSafeEqual rather than ===. Docs: https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b

    Hope that helps — and let us know if the raw version fixes 2044.

  2. Permalink

    const payload = JSON.stringify(body);

    This line is your problem. The partner signed their own bytes, not what your JSON.stringify makes. For me, "the JSON payload" in these docs always means the raw body. Hash delivery.raw as it is, and parse after the check.

    https://nodejs.org/api/crypto.html

  3. Permalink

    Agreed on hashing the raw body, though I'd push back a bit on "the JSON payload always means raw". Docs say that loosely all the time, which is probably why this bit tess.p in the first place, right?

    One caveat for the replay checker: if the saved deliveries were read as a utf8 string and written back, a BOM or CRLF change would break the digest in the same way. I'd keep raw as a Buffer end to end. And if this ever sits behind Express, express.json() has already parsed the body by the time you see it. Two ways round that:

    // option A: keep the bytes next to the parsed body
    app.use(express.json({
      verify: (req, _res, buf) => { req.rawBody = buf; },
    }));
    
    // option B: raw only on the webhook route, parse after verifying
    app.post("/hooks/billing", express.raw({ type: "application/json" }), (req, res) => {
      // req.body is a Buffer here
    });

    I lean towards B because it's harder to hash the wrong thing by accident, but A is less invasive on an existing app. Not sure which I'd defend in review.

Other sides of the same question

  1. -race reports DATA RACE even though ack is an unbuffered receive. Is time.After to blame?

    Is it possible that the race detector is wrong here, or that I misunderstand what the ack gives me? This is pushgate, a small…

    Replies 4Latest activity go
  2. memset before free passes at default flags, fails with OPT=-O2

    I'm working on a small C credential holder called vault, built in WSL. vault_release() is supposed to wipe the secret and the…

    Replies 6Latest activity c
  3. Same overflow check, different error branch at -O0 vs -O2

    Hostile pack with count=65536 and rec_size=32768. At -O0 I get "pack too large". At -O2 I get "out of memory" instead. Same s…

    Replies 6Latest activity c