Webhook signing
Webhook signing adds a cryptographic signature to each webhook request, allowing your server to verify that requests genuinely came from EmailConnect. This prevents attackers from sending forged webhook payloads to your endpoint.
How it works
When webhook signing is enabled:
- EmailConnect generates a unique secret key for your webhook
- For each request, EmailConnect creates an HMAC-SHA256 signature of the JSON request body
- The signature is included as an HTTP header; a timestamp header may also be sent
- Your server verifies the signature using the shared secret
Signature headers
EmailConnect sends the following headers with signed webhooks:
| Header | Description |
|---|---|
X-Webhook-Signature |
HMAC-SHA256 signature in format sha256=<hex-signature> |
X-Webhook-Timestamp |
Unix timestamp (seconds) when the request was sent. Informational only — not included in the signature computation and may not always be present |
Enabling webhook signing
For new webhooks
- Create a new webhook in your dashboard
- Toggle "Webhook signing" in the Advanced options section
- Click "Create webhook"
- Copy the generated secret immediately - it won't be shown again
For existing webhooks
- Edit an existing webhook
- Toggle "Webhook signing" to enable it
- Click "Save"
- Copy the generated secret immediately
Regenerating a secret
If you need a new secret (e.g., after a potential compromise):
- Edit your webhook
- Click "Regenerate" next to the signing status
- Copy the new secret
- Update your server with the new secret
Verifying signatures
Step-by-step process
- Extract the
X-Webhook-Signatureheader - Get the raw request body as a string (before any JSON parsing)
- Compute HMAC-SHA256 of the raw body using your webhook secret
- Prepend
sha256=to the hex digest - Compare your computed signature with the received signature using a timing-safe comparison
- Optionally, check the
X-Webhook-Timestampheader (if present) to reject old requests for replay protection
Node.js / Express
const crypto = require('crypto');
function verifyWebhookSignature(req, secret) {
const signature = req.headers['x-webhook-signature'];
if (!signature) {
return false;
}
// Get raw body (requires express.raw() middleware or body-parser with verify)
const rawBody = req.rawBody || JSON.stringify(req.body);
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(expectedSignature, 'utf8')
);
}
// Express middleware
app.use('/webhook', express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
app.post('/webhook/emails', (req, res) => {
const secret = process.env.WEBHOOK_SECRET;
if (!verifyWebhookSignature(req, secret)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Optional: check timestamp for replay protection
const timestamp = req.headers['x-webhook-timestamp'];
if (timestamp) {
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
console.error('Webhook timestamp too old');
return res.status(401).json({ error: 'Request too old' });
}
}
// Process verified webhook
const emailData = req.body;
console.log('Verified email from:', emailData.message.sender.email);
res.status(200).json({ success: true });
});
Python / Flask
import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
def verify_webhook_signature(request):
signature = request.headers.get('X-Webhook-Signature')
if not signature:
return False
# Get raw body
raw_body = request.get_data(as_text=True)
expected_signature = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
raw_body.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Use timing-safe comparison
return hmac.compare_digest(signature, expected_signature)
@app.route('/webhook/emails', methods=['POST'])
def handle_webhook():
if not verify_webhook_signature(request):
return jsonify({'error': 'Invalid signature'}), 401
# Optional: check timestamp for replay protection
timestamp = request.headers.get('X-Webhook-Timestamp')
if timestamp:
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
return jsonify({'error': 'Request too old'}), 401
email_data = request.json
print(f"Verified email from: {email_data['message']['sender']['email']}")
return jsonify({'success': True})
PHP
<?php
function verifyWebhookSignature($payload, $signature, $secret) {
if (empty($signature)) {
return false;
}
$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
// Use timing-safe comparison
return hash_equals($signature, $expectedSignature);
}
// Handle webhook
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Optional: check timestamp for replay protection
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
if (!empty($timestamp)) {
$currentTime = time();
if (abs($currentTime - intval($timestamp)) > 300) {
http_response_code(401);
echo json_encode(['error' => 'Request too old']);
exit;
}
}
$emailData = json_decode($payload, true);
error_log('Verified email from: ' . $emailData['message']['sender']['email']);
http_response_code(200);
echo json_encode(['success' => true]);
Ruby / Sinatra
require 'sinatra'
require 'json'
require 'openssl'
WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
def verify_webhook_signature(request)
signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']
return false unless signature
request.body.rewind
raw_body = request.body.read
expected_signature = 'sha256=' + OpenSSL::HMAC.hexdigest(
'sha256',
WEBHOOK_SECRET,
raw_body
)
# Use timing-safe comparison
Rack::Utils.secure_compare(signature, expected_signature)
end
post '/webhook/emails' do
content_type :json
unless verify_webhook_signature(request)
status 401
return { error: 'Invalid signature' }.to_json
end
# Optional: check timestamp for replay protection
timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP']
if timestamp
current_time = Time.now.to_i
if (current_time - timestamp.to_i).abs > 300
status 401
return { error: 'Request too old' }.to_json
end
end
request.body.rewind
email_data = JSON.parse(request.body.read)
puts "Verified email from: #{email_data['message']['sender']['email']}"
{ success: true }.to_json
end
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
var webhookSecret = os.Getenv("WEBHOOK_SECRET")
func verifyWebhookSignature(r *http.Request, body []byte) bool {
signature := r.Header.Get("X-Webhook-Signature")
if signature == "" {
return false
}
h := hmac.New(sha256.New, []byte(webhookSecret))
h.Write(body)
expectedSignature := "sha256=" + hex.EncodeToString(h.Sum(nil))
// Use constant-time comparison
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
if !verifyWebhookSignature(r, body) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid signature"})
return
}
// Optional: check timestamp for replay protection
timestamp := r.Header.Get("X-Webhook-Timestamp")
if timestamp != "" {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err == nil {
currentTime := time.Now().Unix()
if math.Abs(float64(currentTime-ts)) > 300 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Request too old"})
return
}
}
}
var emailData map[string]interface{}
json.Unmarshal(body, &emailData)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func main() {
http.HandleFunc("/webhook/emails", webhookHandler)
http.ListenAndServe(":8080", nil)
}
n8n (Code node)
If you receive webhooks in n8n, you can verify signatures using a Code node placed after your Webhook trigger node:
const crypto = require('crypto');
const secret = 'YOUR_WEBHOOK_SECRET';
const item = $input.first().json;
const signatureHeader = item.headers['x-webhook-signature'];
if (!signatureHeader) {
throw new Error('Missing X-Webhook-Signature header');
}
const rawBody = JSON.stringify(item.body);
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
const valid = crypto.timingSafeEqual(
Buffer.from(signatureHeader, 'utf8'),
Buffer.from(expectedSignature, 'utf8')
);
if (!valid) {
throw new Error('Invalid webhook signature — request rejected');
}
return $input.all();
Security best practices
Store secrets securely
- Use environment variables, never hardcode secrets
- Use a secrets manager in production (AWS Secrets Manager, HashiCorp Vault, etc.)
- Rotate secrets periodically or after any potential exposure
Verify timestamps
- If the
X-Webhook-Timestampheader is present, consider rejecting requests older than 5 minutes - This provides optional replay attack protection
Use timing-safe comparison
- Always use constant-time string comparison (
crypto.timingSafeEqual,hmac.compare_digest, etc.) - Regular string comparison can leak information through timing differences
Log verification failures
- Log failed signature verifications for security monitoring
- Include relevant details (timestamp, source IP) but never log the secret
Use HTTPS
- Always use HTTPS for webhook endpoints
- HTTPS encrypts the entire request including headers and signature
Troubleshooting
"Invalid signature" errors
Check your secret
- Ensure you're using the correct secret for this webhook
- Secrets are shown only once - regenerate if lost
Check raw body handling
- The signature is computed on the raw JSON request body
- Parsing JSON before verification can change whitespace/formatting
- Use middleware that preserves the raw body
Check timestamp handling
- The
X-Webhook-Timestampheader is informational only and is not part of the signature - It may not be present on all requests
- Do not include the timestamp in your signature computation
Testing signature verification
You can test your verification logic with a simple script:
const crypto = require('crypto');
const secret = 'your-webhook-secret';
const body = '{"test": "payload"}';
const signature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
console.log('X-Webhook-Signature:', signature);
console.log('Body:', body);
Need help?
Having issues with webhook signing?
- Contact support at support@emailconnect.eu
- Include your webhook ID and any error messages