Skip to main content

Verifying Webhook Signatures

Every webhook from KnoxCall includes an HMAC-SHA256 signature. Verifying this signature ensures the webhook:
  1. Came from KnoxCall (not an attacker)
  2. Wasn’t modified in transit

How Signatures Work

When KnoxCall sends a webhook:
  1. Takes the JSON payload
  2. Signs it with your webhook’s secret key using HMAC-SHA256
  3. Includes the signature in the X-Webhook-Signature header
X-Webhook-Signature: sha256=a1b2c3d4e5f6...
Your endpoint should:
  1. Get the signature from the header
  2. Compute the expected signature using the same secret
  3. Compare them (timing-safe comparison)
  4. Reject if they don’t match

Implementation Examples

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();

// IMPORTANT: Use raw body for signature verification
app.use('/webhook', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(payload, signature) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];

  if (!signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }

  if (!verifySignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Signature valid - process the webhook
  const event = JSON.parse(req.body);
  console.log('Received event:', event.event);

  res.status(200).json({ received: true });
});

app.listen(3000);

Python (Flask)

import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')

def verify_signature(payload, signature):
    expected = 'sha256=' + hmac.new(
        WEBHOOK_SECRET.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Webhook-Signature')

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

    if not verify_signature(request.data, signature):
        return jsonify({'error': 'Invalid signature'}), 401

    # Signature valid - process the webhook
    event = request.json
    print(f"Received event: {event['event']}")

    return jsonify({'received': True}), 200

if __name__ == '__main__':
    app.run(port=3000)

PHP

<?php

$webhookSecret = getenv('WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

function verifySignature($payload, $signature, $secret) {
    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $signature);
}

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

if (!verifySignature($payload, $signature, $webhookSecret)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Signature valid - process the webhook
$event = json_decode($payload, true);
error_log("Received event: " . $event['event']);

http_response_code(200);
echo json_encode(['received' => true]);

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
)

var webhookSecret = os.Getenv("WEBHOOK_SECRET")

func verifySignature(payload []byte, signature string) bool {
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write(payload)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Webhook-Signature")

    if signature == "" {
        http.Error(w, `{"error":"Missing signature"}`, http.StatusUnauthorized)
        return
    }

    payload, _ := io.ReadAll(r.Body)

    if !verifySignature(payload, signature) {
        http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
        return
    }

    // Signature valid - process the webhook
    var event map[string]interface{}
    json.Unmarshal(payload, &event)

    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"received":true}`))
}

func main() {
    http.HandleFunc("/webhook", webhookHandler)
    http.ListenAndServe(":3000", nil)
}

Ruby (Sinatra)

require 'sinatra'
require 'json'
require 'openssl'

WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']

def verify_signature(payload, signature)
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
    'sha256',
    WEBHOOK_SECRET,
    payload
  )
  Rack::Utils.secure_compare(expected, signature)
end

post '/webhook' do
  request.body.rewind
  payload = request.body.read
  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']

  unless signature
    halt 401, { error: 'Missing signature' }.to_json
  end

  unless verify_signature(payload, signature)
    halt 401, { error: 'Invalid signature' }.to_json
  end

  # Signature valid - process the webhook
  event = JSON.parse(payload)
  puts "Received event: #{event['event']}"

  { received: true }.to_json
end

Getting Your Webhook Secret

View Secret

  1. Navigate to Webhooks
  2. Click on your webhook
  3. Click Reveal Secret
  4. Copy the secret
The secret looks like: a1b2c3d4e5f6... (64 hex characters)

Regenerate Secret

If your secret is compromised:
  1. Click Regenerate Secret
  2. Copy the new secret immediately
  3. Update your endpoint with the new secret
Regenerating invalidates the old secret immediately. Update your endpoint before regenerating, or webhooks will fail validation.

Common Mistakes

1. Parsing JSON Before Verification

Wrong:
// DON'T DO THIS
app.use(express.json());

app.post('/webhook', (req, res) => {
  // req.body is already parsed - signature will fail!
  const signature = computeSignature(JSON.stringify(req.body));
});
Right:
// Use raw body for webhook routes
app.use('/webhook', express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
  // req.body is the raw string
  const signature = computeSignature(req.body);
});

2. Wrong Secret

Symptoms:
  • All signatures fail
  • No intermittent issues
Fix:
  • Copy secret directly from KnoxCall
  • Ensure no extra whitespace
  • Check environment variable is set

3. Non-Timing-Safe Comparison

Wrong:
// DON'T DO THIS - vulnerable to timing attacks
if (signature === expected) { ... }
Right:
// Use timing-safe comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { ... }

4. Encoding Issues

Symptoms:
  • Signatures fail for certain payloads
  • Works for simple data, fails for special characters
Fix:
  • Use raw bytes, not decoded strings
  • Ensure UTF-8 encoding throughout

Security Best Practices

1. Always Verify Signatures

Never process webhooks without signature verification:
if (!verifySignature(payload, signature)) {
  // Log the attempt for security monitoring
  console.warn('Invalid webhook signature from:', req.ip);
  return res.status(401).json({ error: 'Invalid signature' });
}

2. Store Secrets Securely

  • Use environment variables (not hardcoded)
  • Rotate secrets periodically
  • Use secrets management services in production

3. Reject Missing Signatures

if (!signature) {
  return res.status(401).json({ error: 'Missing signature' });
}

4. Log Verification Failures

Monitor for repeated failures - could indicate an attack:
if (!verifySignature(payload, signature)) {
  logger.warn({
    message: 'Webhook signature verification failed',
    ip: req.ip,
    webhookId: req.headers['x-webhook-id']
  });
}

5. Use HTTPS Only

Signatures protect against tampering, but HTTPS protects against eavesdropping:
  • Always use HTTPS endpoints
  • Reject HTTP in production

Debugging Signature Issues

Step 1: Log Both Signatures

console.log('Received:', signature);
console.log('Expected:', expected);

Step 2: Check Payload

console.log('Payload length:', payload.length);
console.log('Payload:', payload.toString());

Step 3: Verify Secret

console.log('Secret length:', WEBHOOK_SECRET.length);
console.log('Secret first 4 chars:', WEBHOOK_SECRET.substring(0, 4));

Step 4: Test with Known Values

Use the KnoxCall test feature and compare:
  • Test payload vs what you receive
  • Expected signature vs what you compute

Next Steps


Statistics

  • Level: intermediate
  • Time: 8 minutes

Tags

webhooks, security, signatures, hmac, verification