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;
}
}
}