Verifying NICE CXone and Cognigy Webhook Signatures in Node.js
What You Will Build
A production-grade Node.js Express middleware module that verifies incoming webhook signatures from NICE CXone and Cognigy using HMAC-SHA256, enforces timestamp skew limits, prevents replay attacks, handles cryptographic secret rotation, and generates structured audit logs with latency tracking. This tutorial uses the Node.js crypto module and Express framework. The implementation covers raw payload validation, signature verification, and secure event forwarding to an external security gateway.
Prerequisites
- Node.js 18.0 or higher
- Express 4.18 or higher
dotenvpackage for environment configuration- Webhook shared secret configured in NICE CXone or Cognigy admin console
- Required platform permissions:
cxone:webhook:manageorcognigy:webhook:configure(admin console scope for webhook registration) - Note: Webhook signature verification operates on a shared secret model. OAuth scopes are only required when registering or modifying webhook endpoints via the CXone/Cognigy REST API.
Authentication Setup
Webhook verification does not use OAuth tokens. Instead, you configure a shared secret in the NICE CXone or Cognigy webhook configuration panel. The platform signs each outgoing request using HMAC-SHA256 with this secret. You must store the secret securely in environment variables and initialize the verifier before mounting the middleware.
// .env
WEBHOOK_SECRET_PRIMARY=your_cxone_cognigy_shared_secret_v1
WEBHOOK_SECRET_ROTATION_BACKUP=your_previous_secret_v0
MAX_TIMESTAMP_SKEW_SECONDS=300
EXTERNAL_SECURITY_GW_URL=https://security-gateway.internal/api/v1/events
Load the configuration and initialize the verifier state:
import dotenv from 'dotenv';
dotenv.config();
const WEBHOOK_CONFIG = {
secrets: [
process.env.WEBHOOK_SECRET_PRIMARY,
process.env.WEBHOOK_SECRET_ROTATION_BACKUP
].filter(Boolean),
maximumTimestampSkewSeconds: parseInt(process.env.MAX_TIMESTAMP_SKEW_SECONDS, 10) || 300,
externalSecurityGwUrl: process.env.EXTERNAL_SECURITY_GW_URL || ''
};
if (WEBHOOK_CONFIG.secrets.length === 0) {
throw new Error('WEBHOOK_SECRET_PRIMARY is required for signature verification');
}
Implementation
Step 1: Raw Body Parsing and Request Interception
HMAC signatures are calculated over the exact raw request body. Express default body parsers modify the payload by decoding it, which breaks signature verification. You must capture the raw buffer first, then parse it separately.
import express from 'express';
import crypto from 'crypto';
import http from 'http';
const app = express();
// Capture raw body before any JSON parsing
app.use((req, res, next) => {
let data = [];
req.on('data', chunk => data.push(chunk));
req.on('end', () => {
req.rawBody = Buffer.concat(data);
next();
});
});
// Parse JSON after raw capture
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
Step 2: HMAC Calculation and Signature Validation
The platform sends the signature in a header (typically X-Webhook-Signature or platform-specific variants). You reconstruct the expected signature using the raw body and each available secret, then compare using a timing-safe algorithm to prevent side-channel attacks.
function calculateHmacSignature(secret, rawBody) {
return crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
}
function validateSignature(req, res, next) {
const providedSignature = req.headers['x-webhook-signature'] || '';
const rawBody = req.rawBody;
if (!providedSignature) {
return res.status(401).json({ error: 'Missing signature header' });
}
let signatureValid = false;
let matchedSecretIndex = -1;
for (let i = 0; i < WEBHOOK_CONFIG.secrets.length; i++) {
const expectedSignature = calculateHmacSignature(WEBHOOK_CONFIG.secrets[i], rawBody);
if (crypto.timingSafeEqual(Buffer.from(providedSignature), Buffer.from(expectedSignature))) {
signatureValid = true;
matchedSecretIndex = i;
break;
}
}
if (!signatureValid) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
req.matchedSecretIndex = matchedSecretIndex;
next();
}
Step 3: Replay Attack Prevention and Timestamp Skew Enforcement
Replay attacks occur when an attacker intercepts a valid webhook and resends it. You prevent this by validating the request timestamp against a maximum skew limit and tracking processed nonces or timestamp-signature pairs in a cache.
const processedRequests = new Map();
function cleanupProcessedRequests() {
const now = Date.now();
for (const [key, timestamp] of processedRequests) {
if (now - timestamp > WEBHOOK_CONFIG.maximumTimestampSkewSeconds * 1000) {
processedRequests.delete(key);
}
}
}
setInterval(cleanupProcessedRequests, 60000);
function validateTimestampAndReplay(req, res, next) {
const timestampHeader = req.headers['x-webhook-timestamp'];
const signatureHeader = req.headers['x-webhook-signature'];
if (!timestampHeader) {
return res.status(400).json({ error: 'Missing timestamp header' });
}
const requestTime = parseInt(timestampHeader, 10);
const currentTime = Math.floor(Date.now() / 1000);
const skew = Math.abs(currentTime - requestTime);
if (skew > WEBHOOK_CONFIG.maximumTimestampSkewSeconds) {
return res.status(400).json({
error: 'Timestamp skew exceeds maximum limit',
skewSeconds: skew,
maximumAllowed: WEBHOOK_CONFIG.maximumTimestampSkewSeconds
});
}
const replayKey = `${requestTime}-${signatureHeader}`;
if (processedRequests.has(replayKey)) {
return res.status(400).json({ error: 'Replay attack detected' });
}
processedRequests.set(replayKey, currentTime);
next();
}
Step 4: Secret Rotation, Audit Logging, and External Gateway Sync
Secret rotation requires evaluating multiple secrets gracefully. You log verification events with latency metrics, track success rates, and forward verified payloads to an external security gateway for centralized governance.
const auditLog = [];
const metrics = {
totalRequests: 0,
successfulVerifications: 0,
failedVerifications: 0,
averageLatencyMs: 0
};
function logAuditEvent(req, status, latencyMs, details) {
const entry = {
timestamp: new Date().toISOString(),
requestId: req.headers['x-request-id'] || 'unknown',
status,
latencyMs,
ip: req.ip,
userAgent: req.headers['user-agent'],
details
};
auditLog.push(entry);
console.log(JSON.stringify(entry));
}
async function forwardToSecurityGw(req, res, next) {
const startTime = process.hrtime.bigint();
metrics.totalRequests++;
try {
// Attach verification metadata for downstream consumers
req.webhookVerification = {
valid: true,
matchedSecretIndex: req.matchedSecretIndex,
timestamp: req.headers['x-webhook-timestamp'],
verifiedAt: new Date().toISOString()
};
if (WEBHOOK_CONFIG.externalSecurityGwUrl) {
await http.post(WEBHOOK_CONFIG.externalSecurityGwUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'webhook.verified',
payload: req.body,
metadata: req.webhookVerification
})
});
}
const endTime = process.hrtime.bigint();
const latencyMs = Number(endTime - startTime) / 1000000;
metrics.successfulVerifications++;
metrics.averageLatencyMs = (metrics.averageLatencyMs * (metrics.successfulVerifications - 1) + latencyMs) / metrics.successfulVerifications;
logAuditEvent(req, 'success', latencyMs, {
secretIndexUsed: req.matchedSecretIndex,
rotationActive: req.matchedSecretIndex > 0
});
next();
} catch (error) {
const endTime = process.hrtime.bigint();
const latencyMs = Number(endTime - startTime) / 1000000;
metrics.failedVerifications++;
logAuditEvent(req, 'error', latencyMs, { error: error.message });
next(error);
}
}
Complete Working Example
The following script combines all components into a runnable Express server. It exposes a webhook endpoint, applies the verification pipeline, and returns a confirmation response.
import express from 'express';
import crypto from 'crypto';
import dotenv from 'dotenv';
import http from 'http';
dotenv.config();
const WEBHOOK_CONFIG = {
secrets: [
process.env.WEBHOOK_SECRET_PRIMARY,
process.env.WEBHOOK_SECRET_ROTATION_BACKUP
].filter(Boolean),
maximumTimestampSkewSeconds: parseInt(process.env.MAX_TIMESTAMP_SKEW_SECONDS, 10) || 300,
externalSecurityGwUrl: process.env.EXTERNAL_SECURITY_GW_URL || ''
};
if (WEBHOOK_CONFIG.secrets.length === 0) {
throw new Error('WEBHOOK_SECRET_PRIMARY is required for signature verification');
}
const app = express();
const processedRequests = new Map();
const auditLog = [];
const metrics = {
totalRequests: 0,
successfulVerifications: 0,
failedVerifications: 0,
averageLatencyMs: 0
};
function cleanupProcessedRequests() {
const now = Date.now();
for (const [key, timestamp] of processedRequests) {
if (now - timestamp > WEBHOOK_CONFIG.maximumTimestampSkewSeconds * 1000) {
processedRequests.delete(key);
}
}
}
setInterval(cleanupProcessedRequests, 60000);
function calculateHmacSignature(secret, rawBody) {
return crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
}
function validateSignature(req, res, next) {
const providedSignature = req.headers['x-webhook-signature'] || '';
const rawBody = req.rawBody;
if (!providedSignature) {
return res.status(401).json({ error: 'Missing signature header' });
}
let signatureValid = false;
let matchedSecretIndex = -1;
for (let i = 0; i < WEBHOOK_CONFIG.secrets.length; i++) {
const expectedSignature = calculateHmacSignature(WEBHOOK_CONFIG.secrets[i], rawBody);
if (crypto.timingSafeEqual(Buffer.from(providedSignature), Buffer.from(expectedSignature))) {
signatureValid = true;
matchedSecretIndex = i;
break;
}
}
if (!signatureValid) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
req.matchedSecretIndex = matchedSecretIndex;
next();
}
function validateTimestampAndReplay(req, res, next) {
const timestampHeader = req.headers['x-webhook-timestamp'];
const signatureHeader = req.headers['x-webhook-signature'];
if (!timestampHeader) {
return res.status(400).json({ error: 'Missing timestamp header' });
}
const requestTime = parseInt(timestampHeader, 10);
const currentTime = Math.floor(Date.now() / 1000);
const skew = Math.abs(currentTime - requestTime);
if (skew > WEBHOOK_CONFIG.maximumTimestampSkewSeconds) {
return res.status(400).json({
error: 'Timestamp skew exceeds maximum limit',
skewSeconds: skew,
maximumAllowed: WEBHOOK_CONFIG.maximumTimestampSkewSeconds
});
}
const replayKey = `${requestTime}-${signatureHeader}`;
if (processedRequests.has(replayKey)) {
return res.status(400).json({ error: 'Replay attack detected' });
}
processedRequests.set(replayKey, currentTime);
next();
}
function logAuditEvent(req, status, latencyMs, details) {
const entry = {
timestamp: new Date().toISOString(),
requestId: req.headers['x-request-id'] || 'unknown',
status,
latencyMs,
ip: req.ip,
userAgent: req.headers['user-agent'],
details
};
auditLog.push(entry);
console.log(JSON.stringify(entry));
}
app.use((req, res, next) => {
let data = [];
req.on('data', chunk => data.push(chunk));
req.on('end', () => {
req.rawBody = Buffer.concat(data);
next();
});
});
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
app.post('/webhooks/cxone-cognigy',
validateSignature,
validateTimestampAndReplay,
async (req, res, next) => {
const startTime = process.hrtime.bigint();
metrics.totalRequests++;
try {
req.webhookVerification = {
valid: true,
matchedSecretIndex: req.matchedSecretIndex,
timestamp: req.headers['x-webhook-timestamp'],
verifiedAt: new Date().toISOString()
};
if (WEBHOOK_CONFIG.externalSecurityGwUrl) {
await http.post(WEBHOOK_CONFIG.externalSecurityGwUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'webhook.verified',
payload: req.body,
metadata: req.webhookVerification
})
});
}
const endTime = process.hrtime.bigint();
const latencyMs = Number(endTime - startTime) / 1000000;
metrics.successfulVerifications++;
metrics.averageLatencyMs = (metrics.averageLatencyMs * (metrics.successfulVerifications - 1) + latencyMs) / metrics.successfulVerifications;
logAuditEvent(req, 'success', latencyMs, {
secretIndexUsed: req.matchedSecretIndex,
rotationActive: req.matchedSecretIndex > 0
});
res.status(200).json({
status: 'verified',
latencyMs: parseFloat(latencyMs.toFixed(2)),
metrics: { successRate: metrics.successfulVerifications / metrics.totalRequests }
});
} catch (error) {
const endTime = process.hrtime.bigint();
const latencyMs = Number(endTime - startTime) / 1000000;
metrics.failedVerifications++;
logAuditEvent(req, 'error', latencyMs, { error: error.message });
next(error);
}
}
);
app.get('/health/metrics', (req, res) => {
res.json({
metrics,
auditLogCount: auditLog.length,
activeSecrets: WEBHOOK_CONFIG.secrets.length,
replayCacheSize: processedRequests.size
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook verifier running on port ${PORT}`);
});
Common Errors & Debugging
Error: 401 Invalid Webhook Signature
- Cause: The HMAC signature does not match any configured secret. This typically occurs when the raw body is modified before verification, the secret is outdated, or the platform uses a different hashing algorithm.
- Fix: Ensure
req.rawBodyis captured before JSON parsing. Verify the secret matches the value in the NICE CXone or Cognigy webhook configuration. Confirm the platform uses HMAC-SHA256. - Code: The
calculateHmacSignaturefunction usescrypto.createHmac('sha256', secret). Match this exactly to the platform documentation.
Error: 400 Timestamp Skew Exceeds Maximum Limit
- Cause: The difference between the request timestamp and server time exceeds
maximumTimestampSkewSeconds. This indicates clock drift or delayed network routing. - Fix: Synchronize server time using NTP. Increase the skew limit temporarily for debugging, then reduce it to the recommended 300 seconds.
- Code: Adjust
WEBHOOK_CONFIG.maximumTimestampSkewSecondsin environment variables. The middleware calculatesMath.abs(currentTime - requestTime)and rejects values above the threshold.
Error: 400 Replay Attack Detected
- Cause: The same timestamp and signature pair was processed within the skew window. This triggers the replay protection cache.
- Fix: Ensure the sender generates unique timestamps for each request. Do not retry failed requests with identical timestamps. Implement exponential backoff on the sender side.
- Code: The
processedRequestsMap stores${requestTime}-${signatureHeader}. Entries expire automatically aftermaximumTimestampSkewSeconds.
Error: 500 Crypto or Forwarding Failure
- Cause: The external security gateway is unreachable, or the HTTP client throws a network error during synchronous forwarding.
- Fix: Implement timeout limits on the gateway request. Use a message queue for async forwarding in production. Add retry logic with circuit breaker patterns.
- Code: Wrap the
http.postcall in a try-catch block. Log the failure withlogAuditEventand callnext(error)to trigger the Express error handler.