Python compare_digest TypeError on a Sume signature header

compare_digest raises TypeError on non-ASCII str. A tested Python verifier for the Sume webhook signature that compares bytes and rejects an empty secret.

4 min readSume
All posts

If you pass a str containing a non-ASCII character to hmac.compare_digest, Python raises TypeError, so an attacker-controlled signature header can turn your verifier into a 500. Encode both sides to bytes first and the comparison simply returns False.

Sume signs webhooks with HMAC-SHA256 over the timestamp, a dot and the raw body, and sends sume-v1=<hex> in x-sume-webhook-signature. The header can hold several comma-separated entries during secret rotation, newest first, so the verifier has to check each.

A verifier that does not crash

This version rejects an empty secret, rejects timestamps more than 300 seconds from now, compares bytes, and accepts a match against any entry. It was run with three cases and prints True, False, False.

The loop uses a bitwise-or accumulator instead of returning on the first match, so the time spent does not depend on which entry matched. During a rotation the header can hold the signature made with the new secret first and the old one after it, and your receiver may still only know one of them, so you want to accept if any entry matches. Outside a rotation there is just one entry and the loop runs once. Notice also that the timestamp must be all digits before it is parsed, which stops a malformed header from raising ValueError inside the verifier.

import hmac
from hashlib import sha256

def verify(body: bytes, ts: str, header: str, secret: str, now: int) -> bool:
    if not secret or not ts.isdigit() or abs(now - int(ts)) > 300:
        return False
    digest = hmac.new(secret.encode(), ts.encode() + b"." + body, sha256).hexdigest()
    expected = f"sume-v1={digest}".encode()
    ok = False
    for entry in header.split(","):
        # bytes, not str: compare_digest raises TypeError on non-ASCII str
        ok |= hmac.compare_digest(entry.strip().encode(), expected)
    return ok

body, now = b'{"event":"job.completed"}', 1_780_000_000
sig = "sume-v1=" + hmac.new(b"s3cret", b"1780000000." + body, sha256).hexdigest()
print(verify(body, "1780000000", sig, "s3cret", now))        # True
print(verify(body, "1780000000", "sume-v1=é" + "a" * 63, "s3cret", now))  # False, no crash
print(verify(body, "1780000000", sig, "", now))              # False: empty secret

Why each line is there

The bytes comparison is the fix for the TypeError. The empty-secret check matters because HMAC with an empty key still produces a valid-looking digest, so a missing environment variable would otherwise verify forged requests built with the same empty key. The 300 second window is the documented default replay tolerance.

Verifier checks and what each guards (read 2026-10-06, Sume docs)
CheckGuards against
Empty secret rejectedUnset environment variable accepting forgeries
abs(now - ts) > 300 rejectedReplay of a captured delivery
Bytes passed to compare_digestTypeError on non-ASCII header
Loop over comma-separated entriesSecret rotation, where two signatures are sent

Tradeoffs

Verify against the raw request bytes, not a re-serialized JSON object, or the digest will not match. Frameworks that parse the body first must give you the original bytes. The secret comes from GET /v1/webhooks/signing-secret and is per workspace.

To reproduce the original problem, call hmac.compare_digest with two str values where one contains an accented character; Python raises TypeError rather than returning False. Encoding to UTF-8 bytes first, as the sample does, makes both sides bytes, which compare_digest accepts for any content.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume