Skip to main content

Overview

Securing your webhook endpoints is critical to ensure that webhook requests are coming from Gu1 and not from malicious actors. This guide covers how to verify webhook signatures, implement security best practices, and avoid common security mistakes.

Signature Verification

Gu1 signs all webhook requests with an HMAC SHA-256 signature using your webhook secret. The signature is sent in the X-Webhook-Signature header, allowing you to verify that the request is authentic.

How Signature Verification Works

  1. Gu1 generates a signature: When sending a webhook, Gu1 creates an HMAC SHA-256 hash of the raw request body using your webhook secret
  2. Signature is sent in header: The signature is included in the X-Webhook-Signature header
  3. Your server recalculates: Your endpoint recalculates the signature using the same secret and raw body
  4. Compare signatures: If the signatures match, the webhook is authentic
Always verify webhook signatures in production. Without verification, anyone can send fake webhooks to your endpoint and potentially compromise your system.

Signature Verification Examples

Node.js (Express)

Node.js
const express = require('express');
const crypto = require('crypto');

const app = express();

// IMPORTANT: Store raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/webhooks/gu1', async (req, res) => {
  try {
    // Get signature from header
    const signature = req.headers['x-webhook-signature'];
    const webhookSecret = process.env.GU1_WEBHOOK_SECRET;

    // Verify signature
    if (!verifySignature(req.rawBody, signature, webhookSecret)) {
      console.error('Invalid webhook signature');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Process webhook
    const { event, timestamp, payload } = req.body;
    await handleWebhook(event, payload);

    // Return 200 to acknowledge receipt
    res.status(200).json({ success: true });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: error.message });
  }
});

/**
 * Verify HMAC SHA-256 signature
 */
function verifySignature(rawBody, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  return signature === expectedSignature;
}

app.listen(3000);
Critical: You must verify the signature using the raw request body before it’s parsed as JSON. If you verify against the parsed JSON body (e.g., JSON.stringify(req.body)), the signature will not match because JSON formatting may differ.

Python (Flask)

Python
from flask import Flask, request, jsonify
import hmac
import hashlib
import os
import logging

app = Flask(__name__)

@app.route('/webhooks/gu1', methods=['POST'])
def gu1_webhook():
    try:
        # Get signature from header
        signature = request.headers.get('X-Webhook-Signature')
        webhook_secret = os.getenv('GU1_WEBHOOK_SECRET')

        # Get raw body for signature verification
        raw_body = request.get_data(as_text=True)

        # Verify signature
        if not verify_signature(raw_body, signature, webhook_secret):
            logging.error('Invalid webhook signature')
            return jsonify({'error': 'Invalid signature'}), 401

        # Parse payload
        payload = request.json
        event = payload.get('event')
        data = payload.get('payload')

        logging.info(f'Received webhook: {event}')

        # Process the webhook
        handle_webhook(event, data)

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

    except Exception as e:
        logging.error(f'Webhook error: {e}')
        return jsonify({
            'success': False,
            'error': str(e)
        }), 500

def verify_signature(raw_body: str, signature: str, secret: str) -> bool:
    """Verify HMAC SHA-256 signature"""
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        raw_body.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected_signature)

def handle_webhook(event: str, data: dict):
    # Process webhook based on event type
    pass

if __name__ == '__main__':
    app.run(port=3000)
Use hmac.compare_digest() instead of == for comparing signatures in Python. This function performs a timing-safe comparison that prevents timing attacks.

Go (Gin)

Go
package main

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

    "github.com/gin-gonic/gin"
)

type WebhookPayload struct {
    Event     string                 `json:"event"`
    Timestamp string                 `json:"timestamp"`
    Payload   map[string]interface{} `json:"payload"`
}

func main() {
    r := gin.Default()
    r.POST("/webhooks/gu1", handleWebhook)
    r.Run(":3000")
}

func handleWebhook(c *gin.Context) {
    // Read raw body for signature verification
    rawBody, err := ioutil.ReadAll(c.Request.Body)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to read body"})
        return
    }

    // Verify signature
    signature := c.GetHeader("X-Webhook-Signature")
    webhookSecret := os.Getenv("GU1_WEBHOOK_SECRET")

    if !verifySignature(rawBody, signature, webhookSecret) {
        log.Println("Invalid webhook signature")
        c.JSON(401, gin.H{"error": "Invalid signature"})
        return
    }

    // Parse payload
    var payload WebhookPayload
    if err := json.Unmarshal(rawBody, &payload); err != nil {
        c.JSON(400, gin.H{"error": "Invalid JSON"})
        return
    }

    log.Printf("Received webhook: %s", payload.Event)

    // Process webhook
    handleWebhookEvent(payload)

    c.JSON(200, gin.H{
        "success": true,
        "message": "Webhook received",
    })
}

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

    return hmac.Equal([]byte(signature), []byte(expectedSignature))
}

func handleWebhookEvent(payload WebhookPayload) {
    // Process webhook based on event type
}

Raw Body vs Parsed JSON

A common mistake is to verify the signature using the parsed JSON object instead of the raw request body. This will always fail because JSON formatting can differ.
// WRONG: Verifying parsed body
app.post('/webhooks', express.json(), (req, res) => {
  const signature = req.headers['x-webhook-signature'];

  // This will NOT work - JSON.stringify may format differently
  const body = JSON.stringify(req.body);
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  if (signature === expectedSignature) {
    // Will always fail
  }
});

Idempotency Patterns

Webhooks may be delivered more than once due to network issues, timeouts, or retries. Implement idempotency to ensure you process each webhook only once.

Database-Based Idempotency

Store processed webhook IDs in your database:
Node.js
async function handleWebhook(webhook) {
  const webhookId = `${webhook.payload.entityId}_${webhook.event}_${webhook.timestamp}`;

  // Check if already processed
  const alreadyProcessed = await db.webhookLog.findUnique({
    where: { webhookId }
  });

  if (alreadyProcessed) {
    console.log('Webhook already processed, skipping');
    return; // Skip duplicate
  }

  // Process webhook
  await processWebhookEvent(webhook);

  // Mark as processed
  await db.webhookLog.create({
    data: {
      webhookId,
      event: webhook.event,
      processedAt: new Date(),
      payload: webhook
    }
  });
}

Cache-Based Idempotency

For high-volume webhooks, use a cache like Redis:
Node.js with Redis
const Redis = require('ioredis');
const redis = new Redis();

async function handleWebhook(webhook) {
  const webhookId = `${webhook.payload.entityId}_${webhook.event}_${webhook.timestamp}`;

  // Try to set the key with NX (only if not exists)
  const result = await redis.set(
    `webhook:${webhookId}`,
    '1',
    'EX', 86400, // Expire after 24 hours
    'NX'
  );

  if (!result) {
    console.log('Webhook already processed, skipping');
    return; // Already processed
  }

  // Process webhook
  await processWebhookEvent(webhook);
}
Python with Redis
import redis
import json

redis_client = redis.Redis(host='localhost', port=6379, db=0)

def handle_webhook(webhook):
    webhook_id = f"{webhook['payload']['entityId']}_{webhook['event']}_{webhook['timestamp']}"

    # Try to set the key with NX (only if not exists)
    result = redis_client.set(
        f"webhook:{webhook_id}",
        "1",
        ex=86400,  # Expire after 24 hours
        nx=True
    )

    if not result:
        print('Webhook already processed, skipping')
        return  # Already processed

    # Process webhook
    process_webhook_event(webhook)

Security Best Practices

Never skip signature verification in production environments. This is your primary defense against fake webhooks.
// Always check the signature
if (!verifySignature(req.rawBody, signature, secret)) {
  return res.status(401).json({ error: 'Invalid signature' });
}
Configure your webhook endpoints to use HTTPS only. Reject HTTP requests:
app.post('/webhooks', (req, res) => {
  // Check if request is HTTPS
  if (req.protocol !== 'https') {
    return res.status(403).json({ error: 'HTTPS required' });
  }

  // Process webhook...
});
Only process event types you’re expecting:
const ALLOWED_EVENTS = [
  'entity.created',
  'entity.updated',
  'entity.status_changed',
  'kyc.validation_approved'
];

if (!ALLOWED_EVENTS.includes(webhook.event)) {
  console.warn('Unknown event type:', webhook.event);
  return res.status(400).json({ error: 'Unknown event type' });
}
Protect your endpoint from abuse with rate limiting:
const rateLimit = require('express-rate-limit');

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

app.post('/webhooks', webhookLimiter, handleWebhook);
Never hardcode webhook secrets. Use environment variables or secret management:
// Good - environment variable
const secret = process.env.GU1_WEBHOOK_SECRET;

// Bad - hardcoded
const secret = 'abc123'; // Never do this!
For production, use a secret manager:
  • AWS Secrets Manager
  • HashiCorp Vault
  • Azure Key Vault
  • Google Secret Manager
Validate the webhook payload structure before processing:
function isValidEntityPayload(payload) {
  return (
    payload &&
    payload.entity &&
    payload.entity.id &&
    payload.entity.type
  );
}

if (!isValidEntityPayload(webhook.payload)) {
  return res.status(400).json({ error: 'Invalid payload structure' });
}
When comparing signatures, use timing-safe comparison functions to prevent timing attacks:
const crypto = require('crypto');

function verifySignature(rawBody, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}
Log all webhook attempts for auditing and debugging:
async function logWebhook(webhook, success, error = null) {
  await db.webhookLog.create({
    data: {
      event: webhook.event,
      timestamp: webhook.timestamp,
      success,
      error: error?.message,
      payload: webhook,
      processedAt: new Date()
    }
  });
}
Respond with 200 status code quickly to prevent retries. Process heavy work asynchronously:
app.post('/webhooks', async (req, res) => {
  // Verify signature first
  if (!verifySignature(req.rawBody, signature, secret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

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

  // Process asynchronously
  processWebhook(req.body).catch(console.error);
});
If webhook processing fails, store it for retry:
try {
  await processWebhook(webhook);
} catch (error) {
  // Store failed webhook for retry
  await db.failedWebhook.create({
    data: {
      webhook,
      error: error.message,
      retryCount: 0,
      nextRetryAt: new Date(Date.now() + 5 * 60 * 1000) // Retry in 5 minutes
    }
  });

  // Still return 200 to prevent Gu1 from retrying
  res.status(200).json({ success: false });
}

Common Security Mistakes to Avoid

Avoid these common security mistakes that can compromise your webhook endpoints:

1. Skipping Signature Verification

// DON'T DO THIS - No signature verification
app.post('/webhooks', (req, res) => {
  // Processing webhook without verification
  handleWebhook(req.body);
  res.status(200).send('OK');
});
Risk: Anyone can send fake webhooks to your endpoint.

2. Verifying Parsed JSON Instead of Raw Body

// DON'T DO THIS - Verifying parsed body
app.post('/webhooks', express.json(), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const body = JSON.stringify(req.body); // Wrong!

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  // Will always fail
});
Risk: Signature verification will always fail.

3. Using HTTP Instead of HTTPS

// DON'T DO THIS - Accepting HTTP
http://yourapp.com/webhooks
Risk: Webhook payloads can be intercepted in transit.

4. Hardcoding Secrets

// DON'T DO THIS - Hardcoded secret
const secret = 'my-webhook-secret-123';
Risk: Secrets exposed in version control or logs.

5. Not Implementing Idempotency

// DON'T DO THIS - Processing duplicates
app.post('/webhooks', (req, res) => {
  // No check for duplicates
  await createUser(req.body.entity);
  res.status(200).send('OK');
});
Risk: Duplicate webhooks will create duplicate records.

6. Exposing Errors to Clients

// DON'T DO THIS - Exposing internal errors
app.post('/webhooks', async (req, res) => {
  try {
    await processWebhook(req.body);
  } catch (error) {
    res.status(500).json({
      error: error.stack, // Exposing stack trace
      query: error.query  // Exposing database query
    });
  }
});
Risk: Internal information leakage to attackers.

7. Not Validating Event Types

// DON'T DO THIS - Processing unknown events
app.post('/webhooks', (req, res) => {
  // Accepting any event type
  handleAnyEvent(req.body.event, req.body.payload);
});
Risk: Attackers can send arbitrary event types.

8. Using Weak Secrets

// DON'T DO THIS - Weak secret
const secret = '123456';
Risk: Secrets can be brute-forced. Solution: Use strong, randomly generated secrets (at least 32 characters).

Testing Webhook Security

Test Invalid Signatures

# Test with invalid signature
curl -X POST https://yourapp.com/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: invalid-signature" \
  -d '{"event":"entity.created","payload":{}}'

# Should return 401 Unauthorized

Test Replay Attacks

Send the same webhook twice and verify idempotency:
# Send webhook once
curl -X POST https://yourapp.com/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: valid-signature" \
  -d '{"event":"entity.created","timestamp":"2025-01-07T10:00:00Z","payload":{"entity":{"id":"123"}}}'

# Send same webhook again
curl -X POST https://yourapp.com/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: valid-signature" \
  -d '{"event":"entity.created","timestamp":"2025-01-07T10:00:00Z","payload":{"entity":{"id":"123"}}}'

# Second request should be ignored (idempotency)

Monitoring and Alerting

Set up monitoring for webhook security:
// Track webhook metrics
const metrics = {
  totalReceived: 0,
  signatureVerified: 0,
  signatureFailed: 0,
  processed: 0,
  failed: 0
};

app.post('/webhooks', (req, res) => {
  metrics.totalReceived++;

  const signature = req.headers['x-webhook-signature'];
  if (!verifySignature(req.rawBody, signature, secret)) {
    metrics.signatureFailed++;

    // Alert if too many signature failures
    if (metrics.signatureFailed > 10) {
      alertSecurityTeam('High webhook signature failure rate');
    }

    return res.status(401).json({ error: 'Invalid signature' });
  }

  metrics.signatureVerified++;
  // Process webhook...
});

Next Steps