Why does this signature verification fail with an INVALID_SIGNATURE error despite using the correct client secret? I am building a Node.js consumer for Genesys Cloud webhooks. The documentation states the signature is an HMAC-SHA256 of the raw body using the X-Genesys-Signature header. My code:
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', clientSecret);
hmac.update(req.body); // Fails here
const calculated = hmac.digest('hex');
if (calculated !== req.headers['x-genesys-signature']) throw new Error('Invalid');
The req.body is a stringified JSON object. Is Genesys sending the raw buffer or the parsed string for the HMAC calculation? I need to prevent replay attacks on my GraphQL gateway ingress.
I normally fix this by ensuring the raw body buffer is used directly, not a stringified version. Node’s req.body often mutates data. Use req.rawBody or stream listeners. See KB-9921 for stream handling.
This has the hallmarks of a classic encoding mismatch issue, similar to the stream handling gotchas we see when migrating Five9 IVR logic to Architect flows. The suggestion above about using req.rawBody is directionally correct, but it misses a critical detail about how Genesys Cloud formats the signature payload.
When I rebuilt our webhook consumers from Five9, I found that crypto.createHmac expects a Buffer or a string. If req.body is parsed by express.json(), it becomes a JavaScript object, not the raw string payload Genesys signed. Even worse, if you convert the Buffer to a string using .toString('utf8'), you might lose byte-level fidelity depending on your Node version.
Here is the working pattern that bypasses the parser limitation:
Disable body parsing for the webhook route to keep the payload raw.
Use the req stream directly or access the raw buffer.
Ensure the secret is treated as a string, not a Buffer.
const crypto = require('crypto');
// Ensure this route does NOT have express.json() applied
app.post('/webhook', (req, res) => {
const signature = req.headers['x-genesys-signature'];
const clientSecret = process.env.GENESYS_CLIENT_SECRET;
// Create HMAC with the raw buffer from the request stream
// Note: In Express 4.x, you may need to accumulate chunks manually
// if not using a specific raw-body parser middleware.
const hmac = crypto.createHmac('sha256', clientSecret);
// req.body will be a Buffer if using express.raw()
hmac.update(req.body);
const calculatedSignature = hmac.digest('hex');
if (calculatedSignature !== signature) {
return res.status(401).send('INVALID_SIGNATURE');
}
res.status(200).send('OK');
});
The documentation actually says the signature is generated against the exact bytes received. Using express.raw({ type: '*/*' }) ensures req.body is a Buffer. If you stringify it first, the hash will diverge. Have you checked if your client secret has trailing whitespace? That also causes silent 400s.
This has the hallmarks of a classic encoding mismatch where you’re hashing the parsed object instead of the raw payload.
Error: INVALID_SIGNATURE
const hmac = crypto.createHmac('sha256', clientSecret);
hmac.update(req.rawBody); // Must use raw buffer, not req.body
const calculatedSig = hmac.digest('hex');
Stop using express.json() middleware for these endpoints; it destroys the raw body needed for HMAC verification.
Have you tried checking how you are handling the raw body stream in your Express setup? I see the previous suggestions mention req.rawBody, but that property doesn’t exist on standard Express request objects unless you explicitly add it. In my Node.js middleware bridging CXone to Salesforce, I had to disable the default JSON parsing for the webhook endpoint to ensure I got the exact byte sequence Genesys signed.
The issue is likely that express.json() or express.urlencoded() has already parsed the body into an object by the time your route handler runs. Once it is an object, the string representation might differ slightly from the original payload (e.g., key ordering, whitespace), causing the HMAC mismatch. You need to capture the raw data before parsing.
Here is the pattern I use in my services. It works reliably for Event Hub and real-time state webhooks:
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
// Disable default body parsing for this specific route
router.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-genesys-signature'];
const clientSecret = process.env.GENESYS_CLIENT_SECRET; // Or your config source
// Create HMAC with the raw buffer
const hmac = crypto.createHmac('sha256', clientSecret);
hmac.update(req.body); // req.body is now a Buffer due to express.raw()
const calculatedSignature = hmac.digest('hex');
if (calculatedSignature === signature) {
// Verify success, then parse JSON manually if needed
const payload = JSON.parse(req.body.toString());
console.log('Verified payload:', payload);
res.status(200).send('OK');
} else {
console.error('Signature mismatch');
res.status(401).send('Unauthorized');
}
});
Using express.raw() ensures req.body remains a Buffer. This matches exactly what Genesys hashed. I also recommend adding a timestamp check if you want to prevent replay attacks, though strict signature verification usually suffices for most middleware integrations.