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
Always verify webhook signatures. Never process webhooks without verification.
How Signatures Work
- VoiceByAuribus combines the Unix timestamp with the raw request body in the format
{timestamp}.{body} - VoiceByAuribus computes HMAC-SHA256 of this combined payload using your webhook secret
- The signature is sent in the
X-Webhook-Signatureheader assha256=<hex-signature> - The timestamp is sent in the
X-Webhook-Timestampheader - Each delivery also includes
X-Webhook-Id(unique delivery ID) andX-Webhook-Event(event type) headers for idempotency/routing - Your server computes the same HMAC-SHA256 using
{timestamp}.{body}and your secret - Compare the computed signature with the received signature
- Process the webhook only if signatures match
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:
-
Regenerate the secret:
curl -X POST https://api.auribus.io/api/v1/webhooks/subscriptions/{id}/regenerate-secret \
-H "Authorization: Bearer $TOKEN" -
Update your application with the new secret
-
Test webhook verification
-
Monitor for verification failures
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:
- Email: support@auribus.io
- Security Issues: Mark with "SECURITY:" prefix