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
- FortiBlox generates a signature using your webhook secret
- The signature is sent in the
X-Fortiblox-Signatureheader - Your endpoint verifies the signature before processing
- 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'}), 200use 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
- Use raw payload - Verify signature before parsing JSON
- Constant-time comparison - Prevent timing attacks
- Store secret securely - Use environment variables, not code
- 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 renewalSecurity 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
Code Examples
Production-ready webhook implementations
Event Reference
Complete webhook event documentation
Getting Started
Set up your first webhook
Overview
Learn about FortiBlox webhooks
Support
Security concerns? Contact us immediately:
- Security Email: [email protected]
- Discord: discord.gg/fortiblox
- Bug Bounty: fortiblox.com/security