Skip to content

Webhook Signature Verification

Verify webhook signatures using HMAC-SHA256 to ensure events are genuinely from TruthVouch.

Overview

Each webhook includes an HMAC-SHA256 signature in the X-TruthVouch-Signature header. Verify this signature to confirm:

  • Event authenticity (from TruthVouch)
  • Event integrity (not modified in transit)
  • Prevent replay attacks

Signature Header Format

X-TruthVouch-Signature: t=1705314600,v1=abcdef1234567890...

Components:

  • t — Unix timestamp of when event was generated
  • v1 — HMAC-SHA256 signature (hex-encoded)

Verification Algorithm

1. Extract Components

signature_header = "t=1705314600,v1=abcdef1234567890..."
parts = dict(part.split('=', 1) for part in signature_header.split(','))
timestamp = int(parts['t'])
signature = parts['v1']

2. Create Signed Content

Concatenate timestamp and raw request body with a dot:

{timestamp}.{request_body}

Example:

1705314600.{"event_id":"evt-abc","event_type":"alert.detected",...}

3. Compute HMAC

Use webhook secret key and sha256 algorithm:

import hmac
import hashlib
webhook_secret = "whsec_abc123..."
signed_content = f"{timestamp}.{body}"
computed_signature = hmac.new(
webhook_secret.encode(),
signed_content.encode(),
hashlib.sha256
).hexdigest()

4. Compare Signatures

if computed_signature == signature:
# Signature valid!
else:
# Invalid signature - reject event
return 401 Unauthorized

5. Verify Timestamp (Optional)

Prevent replay attacks by rejecting old events:

import time
max_age_seconds = 300 # 5 minutes
now = int(time.time())
if now - timestamp > max_age_seconds:
# Event too old - reject
return 401 Unauthorized

Implementation Examples

Python

import hmac
import hashlib
import json
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_abc123..."
MAX_AGE = 300 # 5 minutes
def verify_signature(signature_header, body):
"""Verify webhook signature."""
if not signature_header:
return False
# Parse signature header
try:
parts = dict(part.split('=', 1) for part in signature_header.split(','))
timestamp = int(parts.get('t', 0))
signature = parts.get('v1', '')
except (ValueError, KeyError):
return False
# Check timestamp
now = int(time.time())
if now - timestamp > MAX_AGE:
return False
# Compute expected signature
signed_content = f"{timestamp}.{body}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode(),
signed_content.encode(),
hashlib.sha256
).hexdigest()
# Compare signatures (constant-time comparison)
return hmac.compare_digest(signature, expected_signature)
@app.route('/webhook', methods=['POST'])
def handle_webhook():
body = request.get_data(as_text=True)
signature_header = request.headers.get('X-TruthVouch-Signature')
# Verify signature
if not verify_signature(signature_header, body):
return {'error': 'Invalid signature'}, 401
# Process event
event = json.loads(body)
process_event(event)
return {'status': 'success'}, 200

Node.js

const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = 'whsec_abc123...';
const MAX_AGE = 300; // 5 minutes
function verifySignature(signatureHeader, body) {
if (!signatureHeader) {
return false;
}
// Parse signature header
const parts = {};
signatureHeader.split(',').forEach(part => {
const [key, value] = part.split('=');
parts[key] = value;
});
const timestamp = parseInt(parts.t);
const signature = parts.v1;
// Check timestamp
const now = Math.floor(Date.now() / 1000);
if (now - timestamp > MAX_AGE) {
return false;
}
// Compute expected signature
const signedContent = `${timestamp}.${body}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedContent)
.digest('hex');
// Compare (constant-time)
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.post('/webhook', (req, res) => {
const signatureHeader = req.get('X-TruthVouch-Signature');
const body = req.rawBody; // Need raw body, not parsed JSON
if (!verifySignature(signatureHeader, body)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(body);
processEvent(event);
res.json({ status: 'success' });
});
// Middleware to store raw body
app.use(express.raw({ type: 'application/json' }));

.NET

using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("webhook")]
public class WebhookController : ControllerBase
{
private const string WEBHOOK_SECRET = "whsec_abc123...";
private const int MAX_AGE = 300; // 5 minutes
private bool VerifySignature(string signatureHeader, string body)
{
if (string.IsNullOrEmpty(signatureHeader))
return false;
// Parse signature header
var parts = signatureHeader.Split(',')
.Select(p => p.Split('='))
.ToDictionary(p => p[0], p => p[1]);
if (!int.TryParse(parts["t"], out var timestamp))
return false;
var signature = parts["v1"];
// Check timestamp
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - timestamp > MAX_AGE)
return false;
// Compute expected signature
var signedContent = $"{timestamp}.{body}";
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WEBHOOK_SECRET)))
{
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedContent));
var expectedSignature = BitConverter.ToString(hash)
.Replace("-", "")
.ToLower();
return signature.Equals(expectedSignature);
}
}
[HttpPost]
public IActionResult HandleWebhook()
{
var signatureHeader = Request.Headers["X-TruthVouch-Signature"].ToString();
var body = new StreamReader(Request.Body).ReadToEndAsync().Result;
if (!VerifySignature(signatureHeader, body))
return Unauthorized(new { error = "Invalid signature" });
var @event = JsonConvert.DeserializeObject<WebhookEvent>(body);
ProcessEvent(@event);
return Ok(new { status = "success" });
}
}

Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const WEBHOOK_SECRET = "whsec_abc123..."
const MAX_AGE = 300
func verifySignature(signatureHeader string, body []byte) bool {
if signatureHeader == "" {
return false
}
// Parse signature header
parts := strings.Split(signatureHeader, ",")
sigMap := make(map[string]string)
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) == 2 {
sigMap[kv[0]] = kv[1]
}
}
timestamp, err := strconv.ParseInt(sigMap["t"], 10, 64)
if err != nil {
return false
}
signature := sigMap["v1"]
// Check timestamp
now := time.Now().Unix()
if now-timestamp > MAX_AGE {
return false
}
// Compute expected signature
signedContent := fmt.Sprintf("%d.%s", timestamp, string(body))
h := hmac.New(sha256.New, []byte(WEBHOOK_SECRET))
h.Write([]byte(signedContent))
expectedSignature := hex.EncodeToString(h.Sum(nil))
// Compare
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
signatureHeader := r.Header.Get("X-TruthVouch-Signature")
body, _ := io.ReadAll(r.Body)
if !verifySignature(signatureHeader, body) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process event
processEvent(body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"success"}`))
}
func main() {
http.HandleFunc("/webhook", handleWebhook)
http.ListenAndServe(":8080", nil)
}

Getting Your Webhook Secret

  1. Go to Settings → Webhooks
  2. Create or edit webhook
  3. Copy “Signing Secret” (starts with whsec_)
  4. Store securely (use environment variable, secrets manager)

Never commit webhook secret to version control!

Testing Signatures

Generate Test Signature

import hmac
import hashlib
import time
webhook_secret = "whsec_abc123..."
timestamp = int(time.time())
body = '{"event_id":"evt-test"}'
signed_content = f"{timestamp}.{body}"
signature = hmac.new(
webhook_secret.encode(),
signed_content.encode(),
hashlib.sha256
).hexdigest()
header = f"t={timestamp},v1={signature}"
print(f"X-TruthVouch-Signature: {header}")

Test with curl

Terminal window
WEBHOOK_SECRET="whsec_abc123..."
TIMESTAMP=$(date +%s)
BODY='{"event_id":"evt-test","event_type":"alert.detected"}'
SIGNED_CONTENT="$TIMESTAMP.$BODY"
SIGNATURE=$(echo -n "$SIGNED_CONTENT" | openssl dgst -sha256 -mac HMAC -macopt "key:$WEBHOOK_SECRET" -hex | cut -d' ' -f2)
curl -X POST https://yourserver.com/webhook \
-H "Content-Type: application/json" \
-H "X-TruthVouch-Signature: t=$TIMESTAMP,v1=$SIGNATURE" \
-d "$BODY"

Best Practices

  1. Use constant-time comparison — Prevents timing attacks

    • Python: hmac.compare_digest()
    • Node.js: crypto.timingSafeEqual()
    • .NET: Implement yourself or use BouncyCastle
  2. Verify timestamp — Reject events older than 5 minutes

  3. Store secret securely — Use environment variable or secrets manager

  4. Rotate secrets periodically — Every 90 days or if compromised

  5. Log signature failures — Monitor for attacks

  6. Use HTTPS only — Encrypt in transit

See Webhook Events and Testing for related topics.