Signature Verification
Verify BlameTrail webhook signatures using HMAC-SHA256 to ensure authenticity and protect against replay attacks.
Every BlameTrail webhook delivery is signed with HMAC-SHA256 using your endpoint's signing secret. You should verify the signature on every incoming request before processing the payload. This prevents forged requests and replay attacks.
Signature headers
Each webhook delivery includes four custom headers:
| Header | Description | Example |
|---|---|---|
X-BlameTrail-Signature | HMAC-SHA256 signature | sha256=a1b2c3d4e5f6... |
X-BlameTrail-Timestamp | Unix epoch timestamp of the delivery | 1711028400 |
X-BlameTrail-Event | The event type | incident.opened |
X-BlameTrail-Delivery | Unique delivery ID | del_a1b2c3d4... |
Verification algorithm
To verify a webhook signature:
- Extract the
X-BlameTrail-TimestampandX-BlameTrail-Signatureheaders. - Read the raw request body as a string (do not parse it first).
- Construct the signed content:
${timestamp}.${raw_body} - Compute HMAC-SHA256 of the signed content using your signing secret.
- Compare the computed signature to the signature in the header using a constant-time comparison.
- Optionally, check that the timestamp is within a 5-minute window to prevent replay attacks.
Node.js example
import crypto from "crypto";
function verifyWebhookSignature(req, signingSecret) {
const signature = req.headers["x-blametrail-signature"];
const timestamp = req.headers["x-blametrail-timestamp"];
const rawBody = req.rawBody; // Must be the raw string, not parsed JSON
if (!signature || !timestamp) {
return false;
}
// Replay protection: reject requests older than 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);
if (Math.abs(currentTime - requestTime) > 300) {
return false;
}
// Compute the expected signature
const signedContent = `${timestamp}.${rawBody}`;
const expectedSignature =
"sha256=" +
crypto
.createHmac("sha256", signingSecret)
.update(signedContent)
.digest("hex");
// Constant-time comparison to prevent timing attacks
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(signature);
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}Express.js usage
import express from "express";
const app = express();
// Important: capture the raw body before JSON parsing
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
},
})
);
app.post("/webhook", (req, res) => {
const isValid = verifyWebhookSignature(req, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = req.body;
console.log(`Received event: ${event.type}`);
// Process the event
// ...
res.status(200).json({ received: true });
});Python example
import hmac
import hashlib
import time
def verify_webhook_signature(headers, raw_body, signing_secret):
signature = headers.get("X-BlameTrail-Signature", "")
timestamp = headers.get("X-BlameTrail-Timestamp", "")
if not signature or not timestamp:
return False
# Replay protection: reject requests older than 5 minutes
current_time = int(time.time())
request_time = int(timestamp)
if abs(current_time - request_time) > 300:
return False
# Compute the expected signature
signed_content = f"{timestamp}.{raw_body}"
expected_signature = "sha256=" + hmac.new(
signing_secret.encode("utf-8"),
signed_content.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected_signature, signature)Flask usage
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
raw_body = request.get_data(as_text=True)
is_valid = verify_webhook_signature(
request.headers, raw_body, WEBHOOK_SECRET
)
if not is_valid:
return jsonify({"error": "Invalid signature"}), 401
event = request.get_json()
print(f"Received event: {event['type']}")
# Process the event
# ...
return jsonify({"received": True}), 200Replay protection
The timestamp in X-BlameTrail-Timestamp indicates when BlameTrail created the delivery. By checking that this timestamp is within 5 minutes of the current time, you prevent attackers from replaying captured webhook deliveries at a later time.
The 5-minute window accounts for clock drift and network delays. If your server's clock is significantly out of sync, you may need to use NTP to correct it.
Secret rotation
When you rotate a webhook endpoint's signing secret:
- Update your verification code to accept both the old and new secrets temporarily.
- Rotate the secret in BlameTrail via the endpoint settings.
- After confirming new deliveries verify successfully, remove the old secret from your code.
During the transition period, try verifying with the new secret first, then fall back to the old secret:
function verifyWithRotation(req, newSecret, oldSecret) {
if (verifyWebhookSignature(req, newSecret)) {
return true;
}
if (oldSecret && verifyWebhookSignature(req, oldSecret)) {
return true;
}
return false;
}Common mistakes
| Mistake | Problem | Solution |
|---|---|---|
| Parsing JSON before extracting raw body | The signature is computed over the raw string, not the re-serialized JSON | Capture the raw body before parsing |
| Using simple string comparison | Vulnerable to timing attacks | Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python) |
| Skipping timestamp check | Vulnerable to replay attacks | Always check that the timestamp is within 5 minutes |
| Using the wrong encoding | Signature mismatch | Use UTF-8 encoding for both the signing secret and the signed content |