Skip to main content

Webhook Signature Verification

Securely verify webhook requests using HMAC-SHA256 signatures.

Overview

All webhook notifications from VoiceByAuribus are signed with HMAC-SHA256 to ensure they originated from our servers and haven't been tampered with. You must verify signatures to prevent unauthorized access to your webhook endpoint.

Why Signature Verification Matters

Without signature verification, attackers could:

  • Send fake webhook notifications to your endpoint
  • Trigger unauthorized actions in your system
  • Access sensitive business logic
  • Cause data inconsistencies
Critical Security Requirement

Always verify webhook signatures. Never process webhooks without verification.

How Signatures Work

  1. VoiceByAuribus combines the Unix timestamp with the raw request body in the format {timestamp}.{body}
  2. VoiceByAuribus computes HMAC-SHA256 of this combined payload using your webhook secret
  3. The signature is sent in the X-Webhook-Signature header as sha256=<hex-signature>
  4. The timestamp is sent in the X-Webhook-Timestamp header
  5. Each delivery also includes X-Webhook-Id (unique delivery ID) and X-Webhook-Event (event type) headers for idempotency/routing
  6. Your server computes the same HMAC-SHA256 using {timestamp}.{body} and your secret
  7. Compare the computed signature with the received signature
  8. Process the webhook only if signatures match
Replay Attack Prevention

The timestamp allows you to reject old webhooks. Consider rejecting requests with timestamps older than 5 minutes to prevent replay attacks.

Signature Header Format

X-Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
X-Webhook-Event: conversion_completed
X-Webhook-Timestamp: 1736937600
X-Webhook-Signature: sha256=a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef

The value is always prefixed with sha256= followed by the hexadecimal signature.

Header vs Payload Event Naming

X-Webhook-Event is derived from the internal event enum and uses underscore format (e.g. conversion_completed). The webhook JSON payload uses dot notation in event (e.g. conversion.completed).

Implementation

import crypto from 'crypto';

function verifyWebhookSignature(
timestamp: string,
rawBody: string,
signature: string,
secret: string
): boolean {
// Remove 'sha256=' prefix
const receivedSignature = signature.replace('sha256=', '');

// Build the signed payload: timestamp.body
const signedPayload = `${timestamp}.${rawBody}`;

// Compute HMAC-SHA256
const hmac = crypto.createHmac('sha256', secret);
hmac.update(signedPayload);
const computedSignature = hmac.digest('hex');

// Use constant-time comparison to prevent timing attacks
if (computedSignature.length !== receivedSignature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(computedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}

// Express.js middleware
import express from 'express';

app.post(
'/webhooks/voice-conversions',
express.raw({ type: 'application/json' }), // Important: Get raw body
(req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
const rawBody = req.body.toString('utf8');
const secret = process.env.WEBHOOK_SECRET!;

if (!signature || !timestamp) {
return res.status(401).send('Missing signature or timestamp');
}

// Optional: Reject old timestamps (prevent replay attacks)
// Recommended: accept only a small clock-skew window (e.g. ±5 minutes)
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
const maxSkewMs = 5 * 60 * 1000;
if (Number.isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > maxSkewMs) {
return res.status(401).send('Timestamp too old');
}

if (!verifyWebhookSignature(timestamp, rawBody, signature, secret)) {
return res.status(401).send('Invalid signature');
}

// Signature verified - safe to process
const event = JSON.parse(rawBody);
// Process event...

res.status(200).send('OK');
}
);

Security Best Practices

1. Use Constant-Time Comparison

Always use constant-time comparison to prevent timing attacks:

// Good: Constant-time comparison
if (sig1.length === sig2.length) {
crypto.timingSafeEqual(Buffer.from(sig1, 'hex'), Buffer.from(sig2, 'hex'));
}

// Bad: Simple string comparison (vulnerable to timing attacks)
if (sig1 === sig2) { /* DON'T DO THIS */ }

Timing attacks analyze response time differences to guess the correct signature.

2. Verify Before Parsing

Verify the signature before parsing the JSON body:

// Good: Verify first
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
const rawBody = req.body.toString('utf8');

if (!verifyWebhookSignature(timestamp, rawBody, signature, secret)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody); // Now safe to parse

// Bad: Parse before verifying
const event = JSON.parse(rawBody); // DON'T DO THIS
if (!verifyWebhookSignature(...)) { return; }

3. Use Raw Request Body

The signature is computed on the raw request body, not the parsed JSON:

// Good: Use raw body middleware
app.post('/webhook', express.raw({ type: 'application/json' }), handler);

// Bad: JSON middleware modifies body
app.post('/webhook', express.json(), handler); // DON'T DO THIS

4. Protect Your Secret

The webhook secret is sensitive - treat it like a password:

// Good: Environment variable
const secret = process.env.WEBHOOK_SECRET;

// Bad: Hardcoded secret
const secret = 'a1b2c3d4...'; // DON'T DO THIS

5. Handle Missing Signatures

Reject requests without signatures:

if (!signature) {
logger.warn('Webhook request without signature', {
ip: req.ip,
timestamp: Date.now(),
});
return res.status(401).send('Missing signature');
}

6. Log Verification Failures

Monitor failed verification attempts:

if (!verifyWebhookSignature(timestamp, rawBody, signature, secret)) {
logger.error('Webhook signature verification failed', {
ip: req.ip,
receivedSignature: signature.substring(0, 16) + '...', // Don't log full signature
timestamp: Date.now(),
});
return res.status(401).send('Invalid signature');
}

Common Mistakes

Mistake 1: Parsing Body Before Verification

// WRONG: Body is parsed/modified before verification
app.use(express.json()); // Parses all JSON bodies
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// req.body is already parsed - signature won't match!
verifyWebhookSignature(timestamp, JSON.stringify(req.body), signature, secret); // Will fail
});

// CORRECT: Use raw body
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const rawBody = req.body.toString('utf8');
verifyWebhookSignature(timestamp, rawBody, signature, secret); // Works correctly
}
);

Mistake 2: Simple String Comparison

// WRONG: Vulnerable to timing attacks
if (computedSignature === receivedSignature) { /* DON'T */ }

// CORRECT: Constant-time comparison
if (computedSignature.length === receivedSignature.length) {
crypto.timingSafeEqual(
Buffer.from(computedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}

Mistake 3: Not Removing Prefix

// WRONG: Comparing with prefix
const signatureHeader = 'sha256=abc123...';
const receivedSignature = signatureHeader; // ❌ still has 'sha256=' prefix
// Comparing directly to a computed hex signature will fail

// CORRECT: Remove prefix first
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const rawBody = req.body.toString('utf8');
verifyWebhookSignature(timestamp, rawBody, signature, secret); // Works (verifier removes prefix)

Mistake 4: Wrong Encoding

// WRONG: Using wrong encoding
const rawBody = req.body.toString('base64'); // Wrong!

// CORRECT: UTF-8 encoding
const rawBody = req.body.toString('utf8'); // Correct

Regenerating Secrets

If your webhook secret is compromised:

  1. Regenerate the secret:

    curl -X POST https://api.auribus.io/api/v1/webhooks/subscriptions/{id}/regenerate-secret \
    -H "Authorization: Bearer $TOKEN"
  2. Update your application with the new secret

  3. Test webhook verification

  4. Monitor for verification failures

Secret Shown Once

The new secret is shown only once in the response. Save it immediately.

Testing Signature Verification

Generate Test Signature

// Generate a test webhook with valid signature
const testPayload = {
event: 'webhook.test',
id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
timestamp: new Date().toISOString(),
data: {
message: 'This is a test webhook from VoiceByAuribus API',
subscription_id: '880e8400-e29b-41d4-a716-446655440003'
},
};

const rawBody = JSON.stringify(testPayload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const secret = process.env.WEBHOOK_SECRET;

// Build signed payload: timestamp.body
const signedPayload = `${timestamp}.${rawBody}`;

const hmac = crypto.createHmac('sha256', secret);
hmac.update(signedPayload);
const signature = `sha256=${hmac.digest('hex')}`;

// Send test request with signature and timestamp headers
await fetch('https://your-app.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': timestamp,
},
body: rawBody,
});

Test Invalid Signatures

// Test that invalid signatures are rejected
const invalidSignature = 'sha256=invalid';
const timestamp = Math.floor(Date.now() / 1000).toString();

const response = await fetch('https://your-app.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': invalidSignature,
'X-Webhook-Timestamp': timestamp,
},
body: JSON.stringify(testPayload),
});

// Should return 401 Unauthorized
expect(response.status).toBe(401);

Security Checklist

  • Signature verification implemented
  • Using constant-time comparison
  • Verifying before parsing body
  • Using raw request body
  • Secret stored securely (environment variable)
  • Rejecting requests without signatures
  • Logging verification failures
  • Testing with valid and invalid signatures
  • Have secret rotation procedure documented

Next Steps

Getting Help

Security questions about webhooks? Contact us: