How to verify webhook signatures

An unverified webhook is an unauthenticated HTTP request from anyone who learned your URL. Verify every one.

What arrives

POST /diligenceid HTTP/1.1
Content-Type: application/json
X-DiligenceID-Event-Id: whevt_9c1e4b7a
X-DiligenceID-Event-Type: credential.issued
X-DiligenceID-Correlation-Id: corr_5f2c
X-DiligenceID-Timestamp: 1787030462
X-DiligenceID-Signature: sha256=4f2a...

{"eventId":"whevt_9c1e4b7a","eventType":"credential.issued",...}

What is signed

HMAC-SHA256( secret, "{timestamp}.{raw body}" )

Hex-encoded, lowercase, prefixed sha256=.

The timestamp is inside the signed value, which is what stops someone capturing a valid request and replaying it later — the signature and the timestamp cannot be separated.

Sign the raw body, exactly as received. Not a re-serialised object. Parsing and re-encoding JSON changes key order and whitespace, and the signature will not match.

Verifying

using System.Security.Cryptography;
using System.Text;

static bool IsValid(string secret, string timestamp, string signatureHeader, string rawBody)
{
    // Reject anything too old before doing the work. Five minutes is generous for a webhook and short
    // enough that a captured request is not useful for long.
    if (!long.TryParse(timestamp, out var sent)) return false;
    var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(sent);
    if (age > TimeSpan.FromMinutes(5) || age < TimeSpan.FromMinutes(-5)) return false;

    var expected = Convert.ToHexString(
        HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}")))
        .ToLowerInvariant();

    // Constant-time. A plain string comparison returns faster on an earlier mismatch, and that timing
    // difference is enough to recover a signature one byte at a time.
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes($"sha256={expected}"),
        Encoding.UTF8.GetBytes(signatureHeader));
}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function isValid(secret: string, timestamp: string, signatureHeader: string, rawBody: string): boolean {
  const sent = Number(timestamp);
  if (!Number.isFinite(sent)) return false;
  if (Math.abs(Date.now() / 1000 - sent) > 300) return false;

  const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  // timingSafeEqual throws on a length mismatch, so check that first.
  return a.length === b.length && timingSafeEqual(a, b);
}

In Express, express.json() has already replaced the body by the time your handler runs. Use express.raw({ type: 'application/json' }) on the webhook route, or capture the raw buffer with a verify callback.

The order to do things in

1. Read the raw body. Do not parse it yet.
2. Check the timestamp is recent.
3. Compute the HMAC and compare in constant time.
4. Reject with 401 if it does not match. Do not parse an unverified body.
5. Check the event id against what you have already handled.
6. Respond 2xx.
7. Do the work.

Step 4 matters more than it looks: parsing before verifying means running a parser over data from anyone who found your URL.

Steps 5 and 6 are the delivery contract. You will receive the same event more than once — see events.

If verification fails

Return 401 and log the event id and correlation id. Do not process the request, and do not tell the caller why it failed.

Repeated failures usually mean the secret is stale — someone rotated it and the receiver was not updated.

Edit this page on GitHub