Test a Sume webhook receiver: stale timestamp and rotated secret

Two tests every receiver needs: a delivery older than 300 seconds must be refused, and a header with two sume-v1 entries must verify when either secret matches.

5 min readSume
All posts

Write two tests. First, sign a delivery with a timestamp older than 300 seconds and expect your receiver to refuse it. Second, build a header with two sume-v1 entries, newest first, signed with a new and an old secret, and expect the receiver to accept it when either secret matches. A receiver that passes both is correct for the replay window and for a rotation.

These two tests catch the bugs that a happy path test never shows. A receiver that checks only the signature accepts a recorded request forever, and a receiver that compares the whole header to one expected string breaks the moment a rotation starts.

What the receiver must enforce

Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-timestamp with the signature in x-sume-webhook-signature as sume-v1=<hex>. The docs advise to reject callbacks when the timestamp is outside your replay tolerance, and they call five minutes a reasonable default.

The replay window protects you from an attacker who has a valid recorded delivery. The signature covers the timestamp, so the attacker cannot change it without breaking the HMAC, and the window makes the old one useless. Five minutes is the default of the SDK and the value the docs suggest as reasonable.

The stale timestamp test also guards against clock drift. If your server clock is wrong by more than five minutes, every real delivery will fail the check, so keep time synchronised on every host that runs the receiver.

What a rotation does to the header

During a rotation, which lasts 24 hours, Sume signs with both secrets and sends the entries separated by a comma, newest first. A receiver that holds either secret can verify. After the window the old secret stops working. The fingerprint header names the new secret from the moment you rotate.

Plan the order of a rotation around this behaviour. Upgrade the receiver so it accepts a header with several entries, then rotate, then roll out the new secret to every receiver within the 24 hours, and let the old entry expire. If you rotate twice inside one window, Sume retires the secret from two rotations before at once, which is how you make a leak really stop.

Both tests in one file

The tests below use only the standard library and a small verifier. They sign their own fixtures, so they run offline and need no Sume key.

Notice the order in the verifier: it checks the timestamp first, and computes the HMAC only for a fresh request. This keeps a flood of stale or garbage requests cheap. It also means that your test for a stale timestamp passes even if the signature is perfect, which is the point of the test.

Each assertion in the sample maps to one rule: a stale timestamp is refused, the new secret verifies a two entry header, the old secret verifies it too, and an unrelated secret does not. The empty secret check at the top means a missing environment variable crashes the service at the first request, not later, and it never lets a request through unverified.

import hashlib, hmac, time

def sign(secret, ts, body):
    return "sume-v1=" + hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()

def verify(secret, ts, header, body, tolerance=300):
    if not secret:
        raise RuntimeError("empty webhook secret")
    if abs(time.time() - ts) > tolerance:
        return False
    want = sign(secret, ts, body)
    return any(hmac.compare_digest(e.strip(), want) for e in header.split(","))

body, now = b'{"event":"job.completed"}', int(time.time())
old, new = "whsec_old", "whsec_new"

stale = now - 301
assert verify(new, stale, sign(new, stale, body), body) is False

both = sign(new, now, body) + "," + sign(old, now, body)
assert verify(new, now, both, body) and verify(old, now, both, body)
assert verify("whsec_other", now, both, body) is False
print("all webhook checks passed")

Test the route, not only the function

Run the same cases against your real route in an integration test, with a fake clock if your framework supports one. Do not test only the verifier function while the route does its own parsing, because the common production bug is a framework that re-serializes the JSON body before the check. A test that posts the raw bytes through the route catches it.

Add the same two cases to your CI suite and run them against the real handler, not against a copy of the verifier. Then a refactor that drops the window check or the multi-signature support turns the build red.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume