BlameTrail
IntegrationsWebhooks

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:

HeaderDescriptionExample
X-BlameTrail-SignatureHMAC-SHA256 signaturesha256=a1b2c3d4e5f6...
X-BlameTrail-TimestampUnix epoch timestamp of the delivery1711028400
X-BlameTrail-EventThe event typeincident.opened
X-BlameTrail-DeliveryUnique delivery IDdel_a1b2c3d4...

Verification algorithm

To verify a webhook signature:

  1. Extract the X-BlameTrail-Timestamp and X-BlameTrail-Signature headers.
  2. Read the raw request body as a string (do not parse it first).
  3. Construct the signed content: ${timestamp}.${raw_body}
  4. Compute HMAC-SHA256 of the signed content using your signing secret.
  5. Compare the computed signature to the signature in the header using a constant-time comparison.
  6. 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}), 200

Replay 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:

  1. Update your verification code to accept both the old and new secrets temporarily.
  2. Rotate the secret in BlameTrail via the endpoint settings.
  3. 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

MistakeProblemSolution
Parsing JSON before extracting raw bodyThe signature is computed over the raw string, not the re-serialized JSONCapture the raw body before parsing
Using simple string comparisonVulnerable to timing attacksUse crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python)
Skipping timestamp checkVulnerable to replay attacksAlways check that the timestamp is within 5 minutes
Using the wrong encodingSignature mismatchUse UTF-8 encoding for both the signing secret and the signed content

On this page