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 generatedv1— 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 hmacimport 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 Unauthorized5. 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 UnauthorizedImplementation Examples
Python
import hmacimport hashlibimport jsonimport timefrom 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'}, 200Node.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 bodyapp.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
- Go to Settings → Webhooks
- Create or edit webhook
- Copy “Signing Secret” (starts with
whsec_) - Store securely (use environment variable, secrets manager)
Never commit webhook secret to version control!
Testing Signatures
Generate Test Signature
import hmacimport hashlibimport 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
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
-
Use constant-time comparison — Prevents timing attacks
- Python:
hmac.compare_digest() - Node.js:
crypto.timingSafeEqual() - .NET: Implement yourself or use BouncyCastle
- Python:
-
Verify timestamp — Reject events older than 5 minutes
-
Store secret securely — Use environment variable or secrets manager
-
Rotate secrets periodically — Every 90 days or if compromised
-
Log signature failures — Monitor for attacks
-
Use HTTPS only — Encrypt in transit
See Webhook Events and Testing for related topics.