EventSub Signature Verification Failing

Hello,

I’m currently setting up a webhook server to handle Twitch EventSub notifications, but I’m running into an issue with signature verification.

When using the Twitch CLI, the signature matches and everything works as expected. However, when I forward requests from Twitch through ngrok (locally) or an OVH VPS (remotely), the signature validation fails consistently

I’ve logged the headers (Twitch-Eventsub-Message-Id, Twitch-Eventsub-Message-Timestamp, Twitch-Eventsub-Message-Signature) and confirmed they are being received correctly. I’m also using express.raw({ type: 'application/json' }) middleware to ensure the raw body is available for HMAC calculation.

I also tried switching to express.json() with a custom verify function to capture the raw body, but the result was the same — it only works via the Twitch CLI.

Everything else works fine, if I disable the signature check, I’m still able to return the challenge and receive notification events successfully — so the rest of the setup seems correct.

Here’s the relevant code I use for signature verification:

class WebhookServer extends EventEmitter {
  constructor(port, secret) {
    super();
    this.app = express();
    this.port = port || process.env.PORT;
    this.secret = secret || process.env.TWITCH_WEBHOOK_SECRET;

    // Could have written the header names directly in lowercase
    this.TWITCH_MESSAGE_ID = "Twitch-Eventsub-Message-Id".toLowerCase();
    this.TWITCH_MESSAGE_TIMESTAMP = "Twitch-Eventsub-Message-Timestamp".toLowerCase();
    this.TWITCH_MESSAGE_SIGNATURE = "Twitch-Eventsub-Message-Signature".toLowerCase();
    this.MESSAGE_TYPE = "Twitch-Eventsub-Message-Type".toLowerCase();

    this.HMAC_PREFIX = "sha256=";

    this.app.use(
      express.raw({
        type: "application/json", // Need raw message body for signature verification
      })
    );

    this.app.post("/eventsub", (req, res) => this.handleRequest(req, res));
  }

  ...

  handleRequest(req, res) {
    const message = this.getHmacMessage(req);
    const hmac = this.HMAC_PREFIX + this.getHmac(this.secret, message); // Signature to compare

    if (this.verifyMessage(hmac, req.headers[this.TWITCH_MESSAGE_SIGNATURE])) {
      console.log("Signatures match");

      ...

    } else {
      ...

    }
  }

  getHmacMessage(request) {
    return (
      request.headers[this.TWITCH_MESSAGE_ID] +
      request.headers[this.TWITCH_MESSAGE_TIMESTAMP] +
      request.body
    );
  }

  getHmac(secret, message) {
    return crypto.createHmac("sha256", secret).update(message).digest("hex");
  }

  verifyMessage(hmac, twitchSignature) {
    try {
      return crypto.timingSafeEqual(
        Buffer.from(hmac),
        Buffer.from(twitchSignature)
      );
    } catch (err) {
      console.error("Signature verification error:", err.message);
      return false;
    }
  }
}
      

Try my working example (that I use in production behind NGINX also usually on OVH bare metal (rather than VPS)) twitch_misc/eventsub/webhooks/nodejs/receive.js at main · BarryCarlyon/twitch_misc · GitHub

And if that example doesn’t work that suggest something somewhere is interferring unexecptedly with your proxy components

A quick skim I don’t see anything obviously wrong, (without pulling it down and replicating your env) other than functions for the sake of functions

Thanks for the help @BarryCarlyon,

I tested the code you provided, and to go even further, I set up the cleanest environment possible. I created a minimal HTTPS server directly on my computer using your exact code, served with a valid Let’s Encrypt certificate — no proxies, no compression, no middleware noise.

Despite this, the signature still doesn’t match.

To ensure accuracy, I captured the raw packets with Wireshark, decrypted the HTTPS stream using the TLS key log method, and extracted the exact raw request body as Twitch sent it.

I then used that payload for the HMAC signature calculation — concatenated with the Twitch-Eventsub-Message-Id and Twitch-Eventsub-Message-Timestamp. Even then, the calculated signature still differs from what Twitch provides in the Twitch-Eventsub-Message-Signature header.

Additionally, when using the following Pre-request script in Postman against my server with the same request Twitch sends, the signatures match. However, these calculated signatures still differ from the one calculated by Twitch:

// Import the necessary crypto-js library
const CryptoJS = require('crypto-js');

// Your secret key
const secret = 'REDACTED';

// The message components
const messageId = pm.request.headers.get('Twitch-Eventsub-Message-Id');
const timestamp = pm.request.headers.get('Twitch-Eventsub-Message-Timestamp');
const body = pm.request.body.raw;  // Raw body of the request

// Log to verify the raw body content
console.log('Raw Body: ', body);

// Concatenate the message components (Message ID + Timestamp + Raw Body)
const message = messageId + timestamp + body;

// Log the full message to debug
console.log('Message for HMAC: ', message);

// Calculate the HMAC-SHA256 signature using crypto-js
const hmac = CryptoJS.HmacSHA256(message, secret).toString(CryptoJS.enc.Hex);

// Log the HMAC for comparison
console.log('Calculated HMAC: ', hmac);

// Set the calculated signature in the request header
pm.request.headers.add({
  key: 'Twitch-Eventsub-Message-Signature',
  value: `sha256=${hmac}`
});

Despite confirming everything on my end, the signature still doesn’t match what Twitch provides in the request headers.

If you have any insights or suggestions on what could be happening, I’d appreciate it!

Thanks again!

I got nothing obvious then.

Short of something stupid like a stupidily long secret, but that shouldn’t matter.

Since I gave you what I use in production and it’s working fine

Thanks again @BarryCarlyon for the quick replies and your time.

For the secret, I’m using a plain ASCII string, always between 10 and 100 characters long — nothing unusual or too long. So I don’t think that’s the culprit either.

I really appreciate your help. I’ll keep digging into my environment to see what might be interfering, and I’ll make sure to update this thread if I find the root cause.

Turns out the issue was on my end — I was mistakenly using my Twitch client secret instead of my webhook secret in the transport.secret field when creating the subscription. It was leftover from some old code I wrote a while back.

Apologies for the noise, and hopefully this helps someone else avoid the same mistake.

All good now!