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.

Implementation

Node.js / TypeScript

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
return crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(receivedSignature)
);
}

// 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)
const webhookTime = parseInt(timestamp) * 1000;
const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
if (webhookTime < fiveMinutesAgo) {
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');
}
);

Python

import hmac
import hashlib
import time

def verify_webhook_signature(timestamp: str, raw_body: bytes, signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 signature of webhook payload."""
# Remove 'sha256=' prefix
received_signature = signature.replace('sha256=', '')

# Build the signed payload: timestamp.body
signed_payload = f"{timestamp}.".encode('utf-8') + raw_body

# Compute HMAC-SHA256
computed_signature = hmac.new(
secret.encode('utf-8'),
signed_payload,
hashlib.sha256
).hexdigest()

# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(computed_signature, received_signature)

# Flask example
from flask import Flask, request, jsonify
import os

@app.route('/webhooks/voice-conversions', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
raw_body = request.get_data()
secret = os.environ['WEBHOOK_SECRET']

if not signature or not timestamp:
return jsonify({'error': 'Missing signature or timestamp'}), 401

# Optional: Reject old timestamps (prevent replay attacks)
webhook_time = int(timestamp)
current_time = int(time.time())
if current_time - webhook_time > 300: # 5 minutes
return jsonify({'error': 'Timestamp too old'}), 401

if not verify_webhook_signature(timestamp, raw_body, signature, secret):
return jsonify({'error': 'Invalid signature'}), 401

# Signature verified - safe to process
event = request.get_json()
# Process event...

return '', 200

C# / .NET

using System.Security.Cryptography;
using System.Text;

public class WebhookSignatureValidator
{
public static bool VerifySignature(string timestamp, string rawBody, string signature, string secret)
{
// Remove 'sha256=' prefix
var receivedSignature = signature.Replace("sha256=", "");

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

// Compute HMAC-SHA256
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var computedSignature = BitConverter.ToString(hash).Replace("-", "").ToLower();

// Constant-time comparison to prevent timing attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(receivedSignature)
);
}
}

// ASP.NET Core controller
[HttpPost("webhooks/voice-conversions")]
public async Task<IActionResult> ReceiveWebhook()
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();

var signature = Request.Headers["X-Webhook-Signature"].ToString();
var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString();
var secret = Configuration["WebhookSecret"];

if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
{
return Unauthorized(new { error = "Missing signature or timestamp" });
}

// Optional: Reject old timestamps (prevent replay attacks)
if (long.TryParse(timestamp, out var webhookTime))
{
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (currentTime - webhookTime > 300) // 5 minutes
{
return Unauthorized(new { error = "Timestamp too old" });
}
}

if (!WebhookSignatureValidator.VerifySignature(timestamp, rawBody, signature, secret))
{
return Unauthorized(new { error = "Invalid signature" });
}

// Signature verified - safe to process
var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(rawBody);
// Process event...

return Ok();
}

PHP

<?php

function verify_webhook_signature(string $timestamp, string $raw_body, string $signature, string $secret): bool {
// Remove 'sha256=' prefix
$received_signature = str_replace('sha256=', '', $signature);

// Build the signed payload: timestamp.body
$signed_payload = $timestamp . '.' . $raw_body;

// Compute HMAC-SHA256
$computed_signature = hash_hmac('sha256', $signed_payload, $secret);

// Constant-time comparison to prevent timing attacks
return hash_equals($computed_signature, $received_signature);
}

// Example usage
$raw_body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$secret = getenv('WEBHOOK_SECRET');

if (empty($signature) || empty($timestamp)) {
http_response_code(401);
echo json_encode(['error' => 'Missing signature or timestamp']);
exit;
}

// Optional: Reject old timestamps (prevent replay attacks)
$current_time = time();
if ($current_time - intval($timestamp) > 300) { // 5 minutes
http_response_code(401);
echo json_encode(['error' => 'Timestamp too old']);
exit;
}

if (!verify_webhook_signature($timestamp, $raw_body, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}

// Signature verified - safe to process
$event = json_decode($raw_body, true);
// Process event...

http_response_code(200);
echo 'OK';

Security Best Practices

1. Use Constant-Time Comparison

Always use constant-time comparison to prevent timing attacks:

// Good: Constant-time comparison
crypto.timingSafeEqual(Buffer.from(sig1), Buffer.from(sig2));

// 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
if (!verifySignature(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 (!verifySignature(...)) { 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 (!verifySignature(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'];
// req.body is already parsed - signature won't match!
verifySignature(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 rawBody = req.body.toString('utf8');
verifySignature(rawBody, signature, secret); // Works correctly
}
);

Mistake 2: Simple String Comparison

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

// CORRECT: Constant-time comparison
crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(receivedSignature)
);

Mistake 3: Not Removing Prefix

// WRONG: Comparing with prefix
const signature = 'sha256=abc123...';
verifySignature(rawBody, signature, secret); // Will fail

// CORRECT: Remove prefix first
const signature = req.headers['x-webhook-signature'].replace('sha256=', '');
verifySignature(rawBody, signature, secret); // Works

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: 'conversion.completed',
data: { id: 'test-123' },
};

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 response = await fetch('https://your-app.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': invalidSignature,
},
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: