Verifying HMAC Signatures
Secure webhook payload verification
Verifying HMAC Signatures
Every webhook request from daimon.email includes an HMAC-SHA256 signature in the X-Daimon-Signature header. You must verify this signature to ensure the webhook payload is authentic and hasn't been tampered with.
Warning
Never skip signature verification. Without it, attackers can send fake webhook events to your endpoint, potentially triggering unintended actions in your system.
Why HMAC Verification?
Authenticity
Proves the webhook came from daimon.email, not an attacker.
Integrity
Ensures the payload hasn't been modified in transit.
Replay Protection
Combined with timestamp checks, prevents replay attacks.
Zero Trust Security
Never trust incoming data without cryptographic proof.
How HMAC Verification Works
sequenceDiagram
daimon API->>daimon API: Compute HMAC-SHA256(body, secret)
daimon API->>Your Endpoint: POST with X-Daimon-Signature header
Your Endpoint->>Your Endpoint: Compute HMAC-SHA256(body, secret)
Your Endpoint->>Your Endpoint: Compare signatures (timing-safe)
alt Signatures match
Your Endpoint->>daimon API: 200 OK
else Signatures don't match
Your Endpoint->>daimon API: 401 Unauthorized
endSignature Format
X-Daimon-Signature: sha256=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6The signature is a hex-encoded HMAC-SHA256 hash of the raw request body using your webhook secret as the key.
Verification Steps
Extract the signature
Get the X-Daimon-Signature header from the request:
const signature = req.headers['x-daimon-signature'] as string;Get your webhook secret
Retrieve the webhook secret from your secrets manager or environment variables:
const secret = process.env.WEBHOOK_SECRET!;Warning
Never hardcode the webhook secret. Store it securely in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.).
Compute the expected signature
Calculate HMAC-SHA256 of the raw request body using the webhook secret:
import crypto from 'crypto';
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body) // Must be raw body, not parsed JSON
.digest('hex');Note
The signature must be computed on the raw request body, not the parsed JSON object. Most frameworks require special configuration to access the raw body.
Compare signatures (timing-safe)
Use a timing-safe comparison to prevent timing attacks:
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
return res.status(401).send('Invalid signature');
}Warning
Never use === or == for signature comparison. These are vulnerable to timing attacks. Always use crypto.timingSafeEqual() or equivalent.
Process the webhook
Only if the signature is valid, parse and process the webhook payload:
const payload = JSON.parse(req.body.toString());
if (payload.event === 'message.received') {
// Process the message
}Code Examples
Node.js (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
// IMPORTANT: Use express.raw() to get raw body, not express.json()
app.post('/webhook/daimon', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-daimon-signature'] as string;
const secret = process.env.WEBHOOK_SECRET!;
if (!signature) {
console.error('Missing X-Daimon-Signature header');
return res.status(401).send('Unauthorized');
}
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body) // Raw body as Buffer
.digest('hex');
// Timing-safe comparison
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
console.error('Invalid signature');
return res.status(401).send('Unauthorized');
}
// Signature is valid, parse payload
const payload = JSON.parse(req.body.toString());
// Process webhook
console.log(`Received ${payload.event} event`);
// Acknowledge receipt
res.status(200).send('OK');
});
app.listen(3000);Note
Key detail: Use express.raw({ type: 'application/json' }) instead of express.json(). This gives you the raw request body as a Buffer, which is required for signature verification.
Python (Flask)
from flask import Flask, request
import hmac
import hashlib
import os
app = Flask(__name__)
@app.route('/webhook/daimon', methods=['POST'])
def webhook():
signature = request.headers.get('X-Daimon-Signature')
secret = os.environ['WEBHOOK_SECRET'].encode()
if not signature:
print('Missing X-Daimon-Signature header')
return 'Unauthorized', 401
# Compute expected signature
expected_signature = hmac.new(
secret,
request.data, # Raw body as bytes
hashlib.sha256
).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(signature, expected_signature):
print('Invalid signature')
return 'Unauthorized', 401
# Signature is valid, parse payload
payload = request.get_json()
# Process webhook
print(f"Received {payload['event']} event")
# Acknowledge receipt
return 'OK', 200
if __name__ == '__main__':
app.run(port=3000)Note
Key detail: Use request.data (raw bytes) for signature verification, not request.get_json() (parsed object). Use hmac.compare_digest() for timing-safe comparison.
Python (FastAPI)
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import os
app = FastAPI()
@app.post('/webhook/daimon')
async def webhook(request: Request):
signature = request.headers.get('x-daimon-signature')
secret = os.environ['WEBHOOK_SECRET'].encode()
if not signature:
raise HTTPException(status_code=401, detail='Missing signature')
# Read raw body
body = await request.body()
# Compute expected signature
expected_signature = hmac.new(
secret,
body, # Raw body as bytes
hashlib.sha256
).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(status_code=401, detail='Invalid signature')
# Signature is valid, parse payload
payload = await request.json()
# Process webhook
print(f"Received {payload['event']} event")
# Acknowledge receipt
return {'status': 'ok'}Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
)
type WebhookPayload struct {
Event string `json:"event"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Daimon-Signature")
secret := []byte(os.Getenv("WEBHOOK_SECRET"))
if signature == "" {
http.Error(w, "Missing signature", http.StatusUnauthorized)
return
}
// Read raw body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Compute expected signature
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Timing-safe comparison
if subtle.ConstantTimeCompare(
[]byte(signature),
[]byte(expectedSignature),
) != 1 {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature is valid, parse payload
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Process webhook
println("Received", payload.Event, "event")
// Acknowledge receipt
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func main() {
http.HandleFunc("/webhook/daimon", webhookHandler)
http.ListenAndServe(":3000", nil)
}Note
Key detail: Use subtle.ConstantTimeCompare() for timing-safe comparison, not ==. This prevents timing attacks.
Ruby (Sinatra)
require 'sinatra'
require 'openssl'
post '/webhook/daimon' do
signature = request.env['HTTP_X_DAIMON_SIGNATURE']
secret = ENV['WEBHOOK_SECRET']
halt 401, 'Missing signature' if signature.nil?
# Read raw body
request.body.rewind
body = request.body.read
# Compute expected signature
expected_signature = OpenSSL::HMAC.hexdigest(
'SHA256',
secret,
body
)
# Timing-safe comparison
unless Rack::Utils.secure_compare(signature, expected_signature)
halt 401, 'Invalid signature'
end
# Signature is valid, parse payload
payload = JSON.parse(body)
# Process webhook
puts "Received #{payload['event']} event"
# Acknowledge receipt
status 200
body 'OK'
endPHP
<?php
$signature = $_SERVER['HTTP_X_DAIMON_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
if (empty($signature)) {
http_response_code(401);
echo 'Missing signature';
exit;
}
// Read raw body
$body = file_get_contents('php://input');
// Compute expected signature
$expectedSignature = hash_hmac('sha256', $body, $secret);
// Timing-safe comparison
if (!hash_equals($signature, $expectedSignature)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
// Signature is valid, parse payload
$payload = json_decode($body, true);
// Process webhook
error_log("Received {$payload['event']} event");
// Acknowledge receipt
http_response_code(200);
echo 'OK';Note
Key detail: Use hash_equals() for timing-safe comparison. Use php://input to read the raw request body.
Common Mistakes
Parsing body before verification
Wrong:
app.use(express.json()); // Parses body BEFORE signature check
app.post('/webhook', (req, res) => {
const signature = req.headers['x-daimon-signature'];
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(req.body)) // ❌ Won't match
.digest('hex');
});Right:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-daimon-signature'];
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body) // ✅ Raw body as Buffer
.digest('hex');
});Using non-timing-safe comparison
Wrong:
if (signature === expectedSignature) { // ❌ Vulnerable to timing attacks
// Process webhook
}Right:
if (crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) { // ✅ Timing-safe
// Process webhook
}Forgetting to check for missing signature
Wrong:
const signature = req.headers['x-daimon-signature'];
// signature could be undefined
const expectedSignature = crypto.createHmac(...).digest('hex');
if (signature === expectedSignature) { // ❌ Crashes if signature is undefined
// Process webhook
}Right:
const signature = req.headers['x-daimon-signature'];
if (!signature) {
return res.status(401).send('Missing signature');
}
const expectedSignature = crypto.createHmac(...).digest('hex');
if (crypto.timingSafeEqual(...)) { // ✅ Safe
// Process webhook
}Logging or exposing the secret
Wrong:
console.log('Using webhook secret:', secret); // ❌ Leaks secret to logsRight:
// Never log the secret
// Store it in environment variables or secrets manager
const secret = process.env.WEBHOOK_SECRET!;Testing Signature Verification
Manual Test
You can manually generate a test signature to verify your implementation:
#!/bin/bash
SECRET="your-webhook-secret"
PAYLOAD='{"event":"message.received","message":{"id":"msg_test"}}'
# Generate signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
echo "Signature: $SIGNATURE"
# Send test webhook
curl -X POST http://localhost:3000/webhook/daimon \
-H "Content-Type: application/json" \
-H "X-Daimon-Signature: $SIGNATURE" \
-d "$PAYLOAD"Expected output: 200 OK
Unit Test Example
import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
describe('HMAC Signature Verification', () => {
it('should verify valid signatures', () => {
const secret = 'test-secret';
const body = JSON.stringify({ event: 'message.received' });
const signature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
// Simulate verification
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
expect(
crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)
).toBe(true);
});
it('should reject invalid signatures', () => {
const secret = 'test-secret';
const body = JSON.stringify({ event: 'message.received' });
const signature = 'invalid-signature';
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
expect(signature).not.toBe(expectedSignature);
});
});Replay Attack Protection
HMAC signatures prevent tampering but not replay attacks (attacker resends a valid webhook multiple times).
To prevent replay attacks, add timestamp validation:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// 1. Verify HMAC signature (as shown above)
// ...
// 2. Parse payload
const payload = JSON.parse(req.body.toString());
// 3. Check timestamp (reject events older than 5 minutes)
const timestamp = new Date(payload.timestamp);
const now = new Date();
const ageInMinutes = (now.getTime() - timestamp.getTime()) / 1000 / 60;
if (ageInMinutes > 5) {
console.error('Event is too old (possible replay attack)');
return res.status(401).send('Event expired');
}
// 4. Check for duplicate event_id
const eventId = payload.event_id;
const exists = await db.events.exists({ eventId });
if (exists) {
console.log('Duplicate event, already processed');
return res.status(200).send('OK'); // Idempotent
}
// 5. Process event
await processEvent(payload);
// 6. Mark as processed
await db.events.create({ eventId, processedAt: now });
res.status(200).send('OK');
});Rotating Webhook Secrets
If your webhook secret is compromised, rotate it immediately:
curl -X POST https://api.daimon.email/v1/webhooks/wh_abc123/rotate-secret \
-H "Authorization: Bearer dm_live_account123..."Response
{
"result": {
"webhook_id": "wh_abc123",
"new_secret": "whsec_newSecretAbc123...",
"old_secret_revoked_at": "2026-03-16T11:00:00Z"
},
"next_steps": [
"Update your webhook handler with the new secret",
"Old secret is immediately revoked"
]
}Warning
The old secret is immediately revoked. Update your webhook handler with the new secret before rotating to avoid downtime.