Connect by JBRH Open Connect

Verifying a Connect webhook signature

The signature is sha256= followed by the hex HMAC-SHA256 of the string <timestamp>.<raw body>, keyed with the subscription secret, where the timestamp is the value of X-Connect-Timestamp and the body is the exact bytes received. Compare in constant time, and reject a timestamp far from now. Delivery itself is not a registered capability, so this is a specification to build against.

Status
Not yet Planned What this means
Audience
developer
Last verified
Product version
6.3.2

The computation#

Two headers take part. X-Connect-Timestamp carries Unix seconds at signing. X-Connect-Signature carries the digest. The signed string is the timestamp, a single full stop, then the raw request body — not a re-serialisation of the parsed JSON, and not the body with whitespace normalised.

signed_string = X-Connect-Timestamp + "." + raw_body_bytes
digest        = HMAC-SHA256(key = subscription_secret, message = signed_string)
header_value  = "sha256=" + lowercase_hex(digest)

Including the timestamp inside the signed string is what makes the timestamp check meaningful. If the digest covered only the body, an attacker replaying a captured request could rewrite the timestamp header freely and your freshness check would pass on every replay.

Three verifiers#

Each takes the raw body as bytes, the two header values and the secret, and returns a boolean. None of them parses JSON, because parsing before verifying is the mistake the ordering exists to prevent.

import hmac, hashlib, time

def verify(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > 300:                       # five minutes
        return False
    expected = "sha256=" + hmac.new(
        secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(body, signature, timestamp, secret) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;
  const expected =
    "sha256=" +
    createHmac("sha256", secret)
      .update(`${timestamp}.`)
      .update(body)            // body is a Buffer, not a parsed object
      .digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
func Verify(body []byte, signature, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

The four ways a verifier passes its own tests and fails in production#

Verifying a re-serialised body
A framework that hands you a parsed object and a convenience JSON.stringify of it will produce a different byte sequence — key order, spacing, unicode escaping. The digest is over what arrived. Capture the raw bytes in middleware before any parser runs.
Comparing with ==
String equality returns early on the first differing byte, which leaks how much of a guess was right. Both facts and the specification say constant time: hmac.compare_digest, timingSafeEqual, hmac.Equal.
Skipping the timestamp window
Without it, any request ever signed with the current secret is valid forever. Five minutes is a reasonable window; the specification says only to reject a timestamp outside a few minutes.
Testing only the happy path
A verifier that has never been shown a bad signature failing is not known to reject anything. Write the negative test first: flip one byte of the body and assert false.

Secrets, rotation and what to log#

The secret belongs to the subscription, so it is the one value that must never appear in a log line, an error message returned to a caller, or a bug report. Log the delivery attempt identifier from X-Connect-Delivery and the event id instead; both are safe and both are what anyone investigating will ask for.

During a rotation a receiver that accepts either of two secrets keeps working across the change, and one that accepts exactly one does not. Write the verifier to take a list of candidate secrets and return true if any matches — the cost is one extra HMAC per request, which is nothing, and it removes the need to coordinate a cutover.

Questions#

Is the signature over the headers as well?

Only over the timestamp and the body, joined by a full stop. X-Connect-Event is a routing convenience mirrored from the payload, so trust the type inside the verified body rather than the header when the two could disagree.

What encoding is the digest in?

Lowercase hexadecimal, prefixed with sha256=. Compare the whole header value including the prefix, or strip the prefix from both sides before comparing — not one of each, which is a length mismatch that some constant-time helpers report as false and others refuse outright.

Can I test a verifier without receiving a real delivery?

Yes, and that is the sensible way round. Generate a body, pick a timestamp, compute the digest with the same algorithm and feed it to your handler. The test is worth more than a live delivery because you can also generate the failures: stale timestamp, wrong secret, altered body.