FortiBlox LogoFortiBlox Docs
NexusWebhooks

Webhook Security

Best practices for securing FortiBlox webhook endpoints with signature verification and security hardening

Webhook Security

Securing your webhook endpoints is critical to prevent unauthorized access, data tampering, and malicious attacks. This guide covers signature verification, security best practices, and common vulnerabilities.

Why Security Matters

Webhook endpoints are publicly accessible HTTP endpoints that receive sensitive blockchain data. Without proper security:

  • Data tampering - Attackers could send fake events
  • Replay attacks - Old events could be replayed maliciously
  • DDoS attacks - High-volume requests could overwhelm your server
  • Information disclosure - Sensitive data could be exposed
  • Financial loss - Fake payment confirmations could lead to fraud

Signature Verification

Every FortiBlox webhook includes an HMAC-SHA256 signature for verification.

How It Works

  1. FortiBlox generates a signature using your webhook secret
  2. The signature is sent in the X-Fortiblox-Signature header
  3. Your endpoint verifies the signature before processing
  4. Invalid signatures are rejected with 401 Unauthorized

Implementation

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  // Create HMAC with SHA256
  const hmac = crypto.createHmac('sha256', secret);

  // Update with raw payload (as string or buffer)
  hmac.update(JSON.stringify(payload));

  // Generate expected signature
  const expectedSignature = hmac.digest('hex');

  // Constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Express middleware
const verifyWebhook = (req, res, next) => {
  const signature = req.headers['x-fortiblox-signature'];
  const secret = process.env.FORTIBLOX_WEBHOOK_SECRET;

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

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

  next();
};

// Usage
app.post('/webhook', verifyWebhook, (req, res) => {
  // Process verified webhook
  console.log('Verified webhook:', req.body);
  res.status(200).json({ status: 'success' });
});
import hmac
import hashlib
from flask import Flask, request, jsonify

def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
    """Verify webhook signature using HMAC-SHA256."""
    # Generate expected signature
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(signature, expected_signature)

app = Flask(__name__)

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

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

    # Get raw payload
    payload = request.get_data()

    # Verify signature
    if not verify_webhook_signature(payload, signature, secret):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process verified webhook
    event_data = request.json
    print(f"Verified webhook: {event_data}")

    return jsonify({'status': 'success'}), 200
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use hex;
use serde::{Deserialize, Serialize};

type HmacSha256 = Hmac<Sha256>;

#[derive(Deserialize)]
struct WebhookPayload {
    event: String,
    data: serde_json::Value,
}

#[derive(Serialize)]
struct ErrorResponse {
    error: String,
}

fn verify_signature(payload: &str, signature: &str, secret: &str) -> bool {
    // Create HMAC
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
        .expect("HMAC creation failed");

    // Update with payload
    mac.update(payload.as_bytes());

    // Get expected signature
    let expected = hex::encode(mac.finalize().into_bytes());

    // Constant-time comparison
    use subtle::ConstantTimeEq;
    signature.as_bytes().ct_eq(expected.as_bytes()).into()
}

async fn webhook_handler(
    req: HttpRequest,
    body: web::Bytes,
) -> HttpResponse {
    let secret = std::env::var("FORTIBLOX_WEBHOOK_SECRET")
        .expect("FORTIBLOX_WEBHOOK_SECRET not set");

    // Get signature header
    let signature = match req.headers().get("x-fortiblox-signature") {
        Some(sig) => match sig.to_str() {
            Ok(s) => s,
            Err(_) => {
                return HttpResponse::Unauthorized()
                    .json(ErrorResponse {
                        error: "Invalid signature header".to_string()
                    });
            }
        },
        None => {
            return HttpResponse::Unauthorized()
                .json(ErrorResponse {
                    error: "Missing signature".to_string()
                });
        }
    };

    // Verify signature
    let payload_str = String::from_utf8_lossy(&body);
    if !verify_signature(&payload_str, signature, &secret) {
        return HttpResponse::Unauthorized()
            .json(ErrorResponse {
                error: "Invalid signature".to_string()
            });
    }

    // Parse and process webhook
    let payload: WebhookPayload = match serde_json::from_slice(&body) {
        Ok(p) => p,
        Err(_) => {
            return HttpResponse::BadRequest()
                .json(ErrorResponse {
                    error: "Invalid JSON".to_string()
                });
        }
    };

    println!("Verified webhook: {}", payload.event);

    HttpResponse::Ok().json(serde_json::json!({ "status": "success" }))
}
package main

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

type WebhookPayload struct {
    Event string          `json:"event"`
    Data  json.RawMessage `json:"data"`
}

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

    // Constant-time comparison
    return subtle.ConstantTimeCompare(
        []byte(signature),
        []byte(expectedSignature),
    ) == 1
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    secret := os.Getenv("FORTIBLOX_WEBHOOK_SECRET")

    // Get signature header
    signature := r.Header.Get("X-Fortiblox-Signature")
    if signature == "" {
        http.Error(w, "Missing signature", http.StatusUnauthorized)
        return
    }

    // 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.StatusUnauthorized)
        return
    }

    // Parse payload
    var payload WebhookPayload
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Process webhook
    log.Printf("Verified webhook: %s", payload.Event)

    // Respond
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}

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

Important Notes

  1. Use raw payload - Verify signature before parsing JSON
  2. Constant-time comparison - Prevent timing attacks
  3. Store secret securely - Use environment variables, not code
  4. Validate header exists - Always check for signature header

Timestamp Verification

Verify the webhook timestamp to prevent replay attacks:

function verifyTimestamp(timestamp, maxAgeSeconds = 300) {
  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject if timestamp is too old or in the future
  if (age < 0 || age > maxAgeSeconds) {
    return false;
  }

  return true;
}

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

  // Verify timestamp (5 minute window)
  if (!verifyTimestamp(timestamp, 300)) {
    return res.status(401).json({ error: 'Timestamp too old or invalid' });
  }

  // Verify signature and process...
});

Idempotency Protection

Prevent duplicate processing using event IDs:

// Simple in-memory cache (not for production with multiple servers)
const processedEvents = new Set();
const MAX_CACHE_SIZE = 10000;

function isEventProcessed(eventId) {
  return processedEvents.has(eventId);
}

function markEventProcessed(eventId) {
  if (processedEvents.size >= MAX_CACHE_SIZE) {
    // Clear oldest entries (simple approach)
    const iterator = processedEvents.values();
    for (let i = 0; i < 1000; i++) {
      processedEvents.delete(iterator.next().value);
    }
  }
  processedEvents.add(eventId);
}

app.post('/webhook', verifyWebhook, (req, res) => {
  const { event_id, ...eventData } = req.body;

  // Check if already processed
  if (isEventProcessed(event_id)) {
    return res.status(200).json({ status: 'already_processed' });
  }

  // Process event
  processEvent(eventData);

  // Mark as processed
  markEventProcessed(event_id);

  res.status(200).json({ status: 'success' });
});
const redis = require('redis');
const client = redis.createClient();

async function isEventProcessed(eventId) {
  const exists = await client.exists(`webhook:${eventId}`);
  return exists === 1;
}

async function markEventProcessed(eventId, ttl = 86400) {
  // Store with 24-hour TTL
  await client.setex(`webhook:${eventId}`, ttl, '1');
}

app.post('/webhook', verifyWebhook, async (req, res) => {
  const { event_id, ...eventData } = req.body;

  // Check if already processed
  if (await isEventProcessed(event_id)) {
    return res.status(200).json({ status: 'already_processed' });
  }

  // Mark as processed immediately
  await markEventProcessed(event_id);

  // Process event
  try {
    await processEvent(eventData);
    res.status(200).json({ status: 'success' });
  } catch (error) {
    // Remove from cache if processing fails
    await client.del(`webhook:${event_id}`);
    throw error;
  }
});
// PostgreSQL example
const { Pool } = require('pg');
const pool = new Pool();

async function isEventProcessed(eventId) {
  const result = await pool.query(
    'SELECT 1 FROM processed_webhooks WHERE event_id = $1',
    [eventId]
  );
  return result.rows.length > 0;
}

async function markEventProcessed(eventId) {
  await pool.query(
    'INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW()) ON CONFLICT DO NOTHING',
    [eventId]
  );
}

app.post('/webhook', verifyWebhook, async (req, res) => {
  const { event_id, ...eventData } = req.body;

  // Check if already processed
  if (await isEventProcessed(event_id)) {
    return res.status(200).json({ status: 'already_processed' });
  }

  // Use transaction for atomic processing
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // Mark as processed
    await markEventProcessed(event_id);

    // Process event
    await processEvent(eventData, client);

    await client.query('COMMIT');
    res.status(200).json({ status: 'success' });
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
});

IP Whitelisting

Restrict webhook access to FortiBlox IP addresses:

const FORTIBLOX_IPS = [
  '54.158.123.45',
  '54.158.123.46',
  '54.158.123.47',
  '54.158.123.48'
];

function isFortiBloxIP(ip) {
  return FORTIBLOX_IPS.includes(ip);
}

app.post('/webhook', (req, res, next) => {
  // Get client IP (handle proxies)
  const clientIP = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;

  if (!isFortiBloxIP(clientIP)) {
    return res.status(403).json({ error: 'Access denied' });
  }

  next();
});

Dynamic IP List: FortiBlox IP addresses may change. Contact support for the latest list or use signature verification as the primary security mechanism.

Rate Limiting

Protect against high-volume attacks:

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

const webhookLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100, // 100 requests per minute
  message: 'Too many requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
  // Use event_id as key for deduplication
  keyGenerator: (req) => {
    return req.body?.event_id || req.ip;
  }
});

app.post('/webhook', webhookLimiter, verifyWebhook, (req, res) => {
  // Process webhook
});
const requestCounts = new Map();

function rateLimit(req, res, next) {
  const key = req.ip;
  const now = Date.now();
  const windowMs = 60000; // 1 minute
  const maxRequests = 100;

  // Get request count for this IP
  let requests = requestCounts.get(key) || [];

  // Remove old requests
  requests = requests.filter(time => now - time < windowMs);

  // Check if limit exceeded
  if (requests.length >= maxRequests) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      retry_after: Math.ceil((requests[0] + windowMs - now) / 1000)
    });
  }

  // Add current request
  requests.push(now);
  requestCounts.set(key, requests);

  next();
}

app.post('/webhook', rateLimit, verifyWebhook, (req, res) => {
  // Process webhook
});
# nginx.conf
http {
  limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=100r/m;

  server {
    listen 443 ssl;
    server_name api.yourdomain.com;

    location /webhook {
      limit_req zone=webhook_limit burst=20 nodelay;
      limit_req_status 429;

      proxy_pass http://localhost:3000;
      proxy_set_header X-Forwarded-For $remote_addr;
    }
  }
}

HTTPS Enforcement

Always use HTTPS for webhook endpoints:

// Redirect HTTP to HTTPS
app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
    return res.redirect(301, `https://${req.headers.host}${req.url}`);
  }
  next();
});

// Or reject HTTP requests
app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
    return res.status(403).json({ error: 'HTTPS required' });
  }
  next();
});

Error Handling

Proper error handling prevents information disclosure:

app.post('/webhook', async (req, res) => {
  try {
    // Verify signature
    const signature = req.headers['x-fortiblox-signature'];
    if (!signature) {
      // Generic error - don't reveal what's missing
      return res.status(401).json({ error: 'Unauthorized' });
    }

    if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
      // Generic error - don't reveal verification details
      return res.status(401).json({ error: 'Unauthorized' });
    }

    // Process webhook
    await processEvent(req.body);

    res.status(200).json({ status: 'success' });

  } catch (error) {
    // Log error internally
    console.error('Webhook processing error:', error);

    // Don't expose internal error details
    res.status(500).json({ error: 'Internal server error' });
  }
});

Logging and Monitoring

Comprehensive logging for security auditing:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'webhook-errors.log', level: 'error' }),
    new winston.transports.File({ filename: 'webhook.log' })
  ]
});

app.post('/webhook', async (req, res) => {
  const startTime = Date.now();
  const eventId = req.body?.event_id;
  const signature = req.headers['x-fortiblox-signature'];

  try {
    // Log webhook received
    logger.info('Webhook received', {
      event_id: eventId,
      event: req.body?.event,
      ip: req.ip,
      timestamp: new Date().toISOString()
    });

    // Verify signature
    if (!signature || !verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
      logger.warn('Invalid signature', {
        event_id: eventId,
        ip: req.ip,
        has_signature: !!signature
      });
      return res.status(401).json({ error: 'Unauthorized' });
    }

    // Process webhook
    await processEvent(req.body);

    // Log success
    logger.info('Webhook processed', {
      event_id: eventId,
      duration_ms: Date.now() - startTime
    });

    res.status(200).json({ status: 'success' });

  } catch (error) {
    // Log error with context
    logger.error('Webhook processing failed', {
      event_id: eventId,
      error: error.message,
      stack: error.stack,
      duration_ms: Date.now() - startTime
    });

    res.status(500).json({ error: 'Internal server error' });
  }
});

Secret Management

Never hardcode secrets:

# .env (never commit this file!)
FORTIBLOX_WEBHOOK_SECRET=whsec_abc123...

# .env.example (commit this for documentation)
FORTIBLOX_WEBHOOK_SECRET=your_webhook_secret_here
// Load from environment
require('dotenv').config();

const WEBHOOK_SECRET = process.env.FORTIBLOX_WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) {
  throw new Error('FORTIBLOX_WEBHOOK_SECRET not set');
}
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();

async function getWebhookSecret() {
  const response = await secretsManager.getSecretValue({
    SecretId: 'fortiblox/webhook/secret'
  }).promise();

  return JSON.parse(response.SecretString).webhook_secret;
}

// Cache the secret
let cachedSecret = null;

async function getSecret() {
  if (!cachedSecret) {
    cachedSecret = await getWebhookSecret();
  }
  return cachedSecret;
}
const vault = require('node-vault')({
  endpoint: process.env.VAULT_ADDR,
  token: process.env.VAULT_TOKEN
});

async function getWebhookSecret() {
  const response = await vault.read('secret/data/fortiblox/webhook');
  return response.data.data.secret;
}

// Use with caching and automatic renewal

Security Checklist

  • Signature verification implemented and tested
  • Timestamp verification prevents replay attacks
  • Idempotency handling prevents duplicate processing
  • HTTPS only - no HTTP endpoints in production
  • Rate limiting protects against abuse
  • Error handling doesn't leak sensitive information
  • Logging captures security events
  • Secret management uses secure storage
  • IP whitelisting (optional) for additional security
  • Monitoring and alerting configured
  • Regular security audits scheduled
  • Webhook secrets rotated periodically

Common Vulnerabilities

1. Timing Attacks

Vulnerable:

if (signature === expectedSignature) { // BAD - timing attack
  // Process webhook
}

Secure:

if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
  // Process webhook
}

2. Replay Attacks

Vulnerable:

// No timestamp verification
verifySignature(payload, signature, secret);

Secure:

verifySignature(payload, signature, secret);
verifyTimestamp(timestamp, 300); // 5 minute window
checkIdempotency(eventId);

3. Information Disclosure

Vulnerable:

if (!signature) {
  return res.status(401).json({ error: 'Missing X-Fortiblox-Signature header' });
}

Secure:

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

Testing Security

Test your webhook security:

# Test missing signature
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"event":"test"}'

# Test invalid signature
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Fortiblox-Signature: invalid" \
  -d '{"event":"test"}'

# Test valid signature
PAYLOAD='{"event":"test"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "your_secret" | cut -d' ' -f2)

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Fortiblox-Signature: $SIGNATURE" \
  -d "$PAYLOAD"

Next Steps

Support

Security concerns? Contact us immediately: