Last updated

Verifying signatures

Always verify the HMAC-SHA256 signature before processing any payload. Without verification you risk acting on forged requests.

How to verify

  1. Read the raw, unparsed request body as bytes.
  2. Compute sha256={hex} where {hex} is the HMAC-SHA256 hex digest of (secret, raw_body).
  3. Compare your computed value to the X-Webhook-Signature header using a constant-time comparison.

Warning — Use the raw bytes

Use the raw bytes, not re-serialized JSON. Re-encoding the body (e.g. JSON.parse then JSON.stringify) will change byte ordering and whitespace, breaking verification.

Node.js (Express)

const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  const expected = `sha256=${crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')}`;

  // During secret rotation the header may contain two comma-separated signatures.
  const signatures = signatureHeader.split(',').map(s => s.trim());
  return signatures.some(sig => {
    if (sig.length !== expected.length) return false;
    return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

// Use express.raw() so you receive the unparsed body.
app.post(
  '/webhooks/jeeves',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const valid = verifyWebhookSignature(
      req.body,
      req.headers['x-webhook-signature'],
      process.env.JEEVES_WEBHOOK_SECRET
    );
    if (!valid) return res.status(401).send('Invalid signature');

    const event = JSON.parse(req.body);
    // Enqueue for background processing — do NOT block here.
    res.status(200).send('OK');
  }
);

Python (FastAPI / Flask)

import hmac, hashlib

def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    # During secret rotation, header may contain two comma-separated signatures.
    signatures = [s.strip() for s in signature_header.split(',')]
    return any(hmac.compare_digest(sig, expected) for sig in signatures)

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func VerifyWebhookSignature(rawBody []byte, signatureHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    for _, sig := range strings.Split(signatureHeader, ",") {
        sig = strings.TrimSpace(sig)
        if hmac.Equal([]byte(sig), []byte(expected)) {
            return true
        }
    }
    return false
}

Dual signatures during secret rotation

While a rotation is in progress (24-hour grace period), X-Webhook-Signature contains two signatures separated by a comma:

X-Webhook-Signature: sha256=<new_secret_sig>, sha256=<old_secret_sig>

The verifiers above already accept the request if either signature matches.