Skip to content

Signature Verification

Verify webhook authenticity using HMAC SHA256 signatures.

Why Verify Signatures?

Signature verification ensures that:

  1. Webhooks are actually from YeboLink
  2. Payloads haven't been tampered with
  3. Requests aren't replayed by malicious actors

Always Verify

Never process webhook payloads without verifying the signature. This is a critical security measure.

How It Works

  1. YeboLink creates an HMAC SHA256 hash of the request body using your webhook secret
  2. The hash is sent in the X-YeboLink-Signature header
  3. Your server recreates the hash and compares it with the received signature
  4. If they match, the webhook is authentic

Verification Examples

Node.js

javascript
const crypto = require('crypto');

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

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

// Usage in Express
app.post('/webhooks/yebolink', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-yebolink-signature'];
  const secret = process.env.YEBOLINK_WEBHOOK_SECRET;

  if (!verifySignature(req.body, signature, secret)) {
    return res.status(403).send('Invalid signature');
  }

  // Process webhook
  const payload = JSON.parse(req.body);
  handleWebhook(payload);

  res.status(200).send('OK');
});

Use Raw Body

When using Express, you MUST use express.raw() for webhook routes to preserve the raw body needed for signature verification.

Python

python
import hmac
import hashlib

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    """Verify webhook signature"""

    # Create expected signature
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Use timing-safe comparison
    return hmac.compare_digest(signature, expected)

# Usage in Flask
from flask import Flask, request, Response

app = Flask(__name__)

@app.route('/webhooks/yebolink', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-YeboLink-Signature')
    secret = os.environ.get('YEBOLINK_WEBHOOK_SECRET')

    # Get raw body
    payload = request.get_data()

    if not verify_signature(payload, signature, secret):
        return Response('Invalid signature', status=403)

    # Process webhook
    data = request.get_json()
    handle_webhook_data(data)

    return Response('OK', status=200)

PHP

php
<?php
function verifySignature($payload, $signature, $secret) {
    // Create expected signature
    $expected = hash_hmac('sha256', $payload, $secret);

    // Use timing-safe comparison
    return hash_equals($signature, $expected);
}

// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_YEBOLINK_SIGNATURE'] ?? '';
$secret = getenv('YEBOLINK_WEBHOOK_SECRET');

if (!verifySignature($payload, $signature, $secret)) {
    http_response_code(403);
    die('Invalid signature');
}

// Process webhook
$data = json_decode($payload, true);
handleWebhook($data);

http_response_code(200);
echo 'OK';
?>

Ruby

ruby
require 'openssl'

def verify_signature(payload, signature, secret)
  # Create expected signature
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)

  # Use timing-safe comparison
  Rack::Utils.secure_compare(signature, expected)
end

# Usage in Sinatra
post '/webhooks/yebolink' do
  signature = request.env['HTTP_X_YEBOLINK_SIGNATURE']
  secret = ENV['YEBOLINK_WEBHOOK_SECRET']

  # Get raw body
  request.body.rewind
  payload = request.body.read

  unless verify_signature(payload, signature, secret)
    halt 403, 'Invalid signature'
  end

  # Process webhook
  data = JSON.parse(payload)
  handle_webhook(data)

  status 200
  'OK'
end

Go

go
package main

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

func verifySignature(payload []byte, signature, secret string) bool {
    // Create expected signature
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))

    // Use constant-time comparison
    return hmac.Equal([]byte(signature), []byte(expected))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-YeboLink-Signature")
    secret := os.Getenv("YEBOLINK_WEBHOOK_SECRET")

    // Read body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    // Verify signature
    if !verifySignature(body, signature, secret) {
        http.Error(w, "Invalid signature", http.StatusForbidden)
        return
    }

    // Process webhook
    var payload map[string]interface{}
    json.Unmarshal(body, &payload)
    handleWebhookData(payload)

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

Testing Signature Verification

Generate Test Signature

javascript
const crypto = require('crypto');

const secret = 'whsec_your_webhook_secret';
const payload = JSON.stringify({
  event: 'message.delivered',
  data: { message_id: 'test-123' }
});

const signature = crypto
  .createHmac('sha256', secret)
  .update(payload)
  .digest('hex');

console.log('Signature:', signature);

// Send test request
fetch('http://localhost:3000/webhooks/yebolink', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-YeboLink-Signature': signature,
    'X-YeboLink-Event': 'message.delivered'
  },
  body: payload
});

Verify Manually

bash
# Create signature manually
echo -n '{"event":"message.delivered","data":{"message_id":"test-123"}}' | \
  openssl dgst -sha256 -hmac 'whsec_your_webhook_secret'

# Send test request
curl -X POST http://localhost:3000/webhooks/yebolink \
  -H "Content-Type: application/json" \
  -H "X-YeboLink-Signature: GENERATED_SIGNATURE" \
  -H "X-YeboLink-Event: message.delivered" \
  -d '{"event":"message.delivered","data":{"message_id":"test-123"}}'

Common Issues

Issue: Signature Always Invalid

Problem: Using JSON-parsed body instead of raw body

javascript
// WRONG - body has been parsed
app.use(express.json());
app.post('/webhooks', (req, res) => {
  const signature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(req.body)) // ❌ May not match original
    .digest('hex');
});

// CORRECT - use raw body
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = crypto
    .createHmac('sha256', secret)
    .update(req.body) // ✅ Original raw body
    .digest('hex');
});

Issue: Signature Valid in Development, Fails in Production

Problem: Proxy or load balancer modifying request body

Solution: Configure proxy to preserve raw body:

nginx
# Nginx
location /webhooks {
    proxy_pass http://backend;
    proxy_set_header X-Real-IP $remote_addr;
    # Don't buffer request body
    proxy_request_buffering off;
}

Issue: Intermittent Verification Failures

Problem: Using non-constant-time comparison

javascript
// WRONG - vulnerable to timing attacks
if (signature === expected) { }

// CORRECT - use timing-safe comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { }

Security Best Practices

1. Store Secret Securely

bash
# .env file (never commit to git)
YEBOLINK_WEBHOOK_SECRET=whsec_your_webhook_secret

# Add to .gitignore
echo ".env" >> .gitignore

2. Use Timing-Safe Comparison

Always use constant-time comparison functions:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()
  • Ruby: Rack::Utils.secure_compare()
  • Go: hmac.Equal()

3. Reject Invalid Signatures Immediately

javascript
app.post('/webhooks/yebolink', (req, res) => {
  // Verify FIRST, before any processing
  if (!verifySignature(req.body, signature, secret)) {
    return res.status(403).send('Invalid signature');
  }

  // Only process if signature is valid
  processWebhook(req.body);
  res.status(200).send('OK');
});

4. Log Verification Failures

javascript
if (!verifySignature(payload, signature, secret)) {
  console.error('Webhook signature verification failed', {
    timestamp: new Date().toISOString(),
    signature_received: signature,
    ip: req.ip
  });

  return res.status(403).send('Invalid signature');
}

5. Implement Rate Limiting

Protect against brute force attacks:

javascript
const rateLimit = require('express-rate-limit');

const webhookLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: 'Too many webhook requests'
});

app.post('/webhooks/yebolink', webhookLimiter, handleWebhook);

Debugging

Enable Verbose Logging

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

  console.log('Signature Debug:', {
    received: signature,
    expected: expected,
    payload_length: payload.length,
    payload_preview: payload.toString().substring(0, 100)
  });

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Test with Known Values

javascript
// Test with known good values
const testPayload = '{"event":"test"}';
const testSecret = 'test_secret';
const testSignature = crypto
  .createHmac('sha256', testSecret)
  .update(testPayload)
  .digest('hex');

console.assert(
  verifySignature(testPayload, testSignature, testSecret),
  'Verification should pass with known good values'
);

Next Steps

Built with VitePress