Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°57

Verify a Webhook Signature

Medium FDESecurityIntegrations

Every real integration sends signed webhooks — and every FDE has to verify them. Recompute the signature over the payload, compare in constant time, and reject stale events to stop replay attacks. (A provided MAC stands in for HMAC-SHA256 so it runs anywhere; the verify logic is identical.) Solve it in Python or TypeScript, with hidden tests.

The problem

Implement verify(payload, signature, secret, timestamp, now, tolerance). A sender signs "{timestamp}.{payload}" with a shared secret using the provided sign() and sends the result plus the timestamp. You must: (1) reject if the event is too old — |now - timestamp| > tolerance — to block replays; (2) recompute the expected signature; (3) compare it to the received one in constant time. Return True only if it is authentic and fresh.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement verify(...): check the timestamp window first, recompute sign(secret, "{timestamp}.{payload}"), and constant-time-compare it against the received signature.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input payload = "body", signature = sign("sec", "100.body"), secret = "sec", timestamp = 100, now = 105, tolerance = 10
Output True
signature matches and the event is 5 seconds old, within tolerance
EXAMPLE 2
Input payload = "body2", signature = sign("sec", "100.body"), secret = "sec", timestamp = 100, now = 105, tolerance = 10
Output False
tampered payload: the recomputed signature no longer matches
EXAMPLE 3
Input payload = "body", signature = sign("sec", "100.body"), secret = "sec", timestamp = 100, now = 200, tolerance = 10
Output False
stale: 100 seconds old, rejected as a possible replay
EXAMPLE 4
Input payload = "x", signature = sign("s", "50.x"), secret = "s", timestamp = 50, now = 60, tolerance = 10
Output True
edge case: a gap exactly equal to the tolerance is still accepted
CONSTRAINTS
  • Use the provided sign(secret, message) — do not invent your own.
  • The signed message is exactly str(timestamp) + "." + payload.
  • Compare with a constant-time equality (accumulate differences over all characters; never early-return on the first mismatch) so an attacker can’t time their way to a valid signature.
  • Reject events whose timestamp differs from now by more than tolerance.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Bail out early only on the timestamp check: if abs(now - timestamp) > tolerance, return False.
  2. Recompute the expected signature with the provided sign over str(timestamp) + "." + payload.
  3. Constant-time compare: if lengths differ return False; otherwise XOR each pair of characters into an accumulator and require it to be 0.
  4. This mirrors how Stripe/GitHub webhooks work — real code just swaps sign for HMAC-SHA256.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious check is one line: return sign(secret, str(timestamp) + "." + payload) == signature. It returns the right boolean for every input, in O(n) time and O(n) space for an n-character payload, and it is still wrong in two ways. String == can stop at the first differing character, so the response time can leak how much of a forged signature is right; with enough measurements an attacker can build a valid signature one character at a time instead of guessing all of it at once. And with no freshness check, a captured request replays successfully forever.

The approach

Three checks, in a fixed order. The reference verify first rejects anything outside the window, abs(now - timestamp) > tolerance, which catches stale events and ones dated in the future. It then rebuilds exactly what the sender signed, expected = sign(secret, str(timestamp) + "." + payload). Because the timestamp is inside the signed message, an attacker cannot refresh a captured request by editing its timestamp: a new timestamp changes expected.

The comparison returns False when the lengths differ, which reveals nothing secret because every valid signature has the same length, then folds every character pair into diff with diff |= ord(a) ^ ord(b). XOR is 0 only for equal characters and OR never clears a bit, so diff == 0 exactly when every position matches, and the loop always runs to the end, whatever the position of the first mismatch. The TypeScript version uses charCodeAt. In production, swap sign for HMAC-SHA256 and the loop for hmac.compare_digest or crypto.timingSafeEqual.

Walk through an example

The second example, a tampered payload, shows the comparison running past the first mismatch: payload "body2" carrying the signature of "100.body", secret "sec", timestamp 100, now 105, tolerance 10.

stepcheckvalueoutcome
1freshnessabs(105 - 100) = 5, not above 10continue
2expectedsign("sec", "100.body2") = "a1f318cd"—
3received"394d9ecb", signed over "100.body"—
4lengths8 and 8continue
5positions 0 to 3XORs 82, 8, 82, 87diff = 95
6positions 4 to 7XORs 8, 93, 0, 6; the loop still visits every onediff stays 95
7resultdiff == 0 is falsereturn False

Complexity

Time O(n) for an n-character payload: building the message and running sign over it dominate, and the comparison always covers the full signature. Space O(n) for the message string. The one-line == version has the same cost; it is rejected because its timing depends on the data and it never checks freshness, not because it is slower.

Edge cases to test

  • timestamp = 50, now = 60, tolerance = 10 → True, while now = 61 → False. The window is inclusive: only a gap greater than tolerance is rejected.
  • timestamp = 50, now = 30, tolerance = 10 → False. abs also rejects events dated in the future, which a one-sided check, now - timestamp > tolerance, would accept.
  • A captured valid request resent with its timestamp changed from 100 to 200 at now = 205 → False. The timestamp is part of the signed message, so editing it breaks the signature.
  • signature = "" or the prefix "394d" → False, thanks to the length check. Without it, zip compares only the overlapping characters, diff stays 0, and both would verify.
  • signature = "394D9ECB", the right signature in uppercase → False. Hex digits are compared case-sensitively; normalize case first if a sender might uppercase them.
  • A signature made with the wrong secret → False: sign("wrong", "100.body") is "6c1906f6", not "394d9ecb".

Common mistakes

  • Comparing signatures with == — the early-exit leaks timing; use a constant-time compare.
  • Hashing the parsed-and-re-serialized body instead of the exact raw bytes the sender signed.
  • Skipping the timestamp check, leaving you open to replay of a captured valid request.

Follow-ups an interviewer asks next

Replace the stand-in with real HMAC-SHA256.
In Python, hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest(), then hmac.compare_digest(expected, signature). In Node, crypto.createHmac("sha256", secret).update(message).digest("hex"), then crypto.timingSafeEqual on two Buffers; it throws a RangeError when their lengths differ, so check the length first. Sign the raw request bytes, never a re-serialized copy.
Block a replay inside the tolerance window.
The timestamp check alone still accepts the same request repeated within the window. Record each event’s unique id, or the signature itself, in a store with an expiry slightly longer than the tolerance, and reject one already seen. Make the handler idempotent as well, since providers also redeliver events on purpose when a delivery seems to fail.
Rotate the secret without dropping events.
During an overlap period, accept a signature made with either the old or the new secret: compute both expected values, compare each in constant time, and pass if either matches. Some providers send several signatures in one header for exactly this reason, one per active secret. When the overlap ends, delete the old secret.
The receiver’s clock drifts.
The tolerance has to cover clock skew plus delivery delay, so run time synchronization such as NTP on every receiver. Too small a window rejects genuine events, and too large a window widens the replay window. Log each rejection with the observed difference between now and timestamp, so drift shows up as a trend instead of a mystery.

Where this shows up

Webhook signature verification is how you trust that an inbound webhook truly came from Stripe, GitHub, or your provider and was not forged or replayed. The raw-body + constant-time-compare + timestamp trio is the standard, and getting any one of the three wrong is a genuine security hole.

Reference solution

Reference solution — Python
def _h(s):                          # provided: a small deterministic MAC (stand-in for HMAC)
    h = (1779033703 ^ len(s)) & 0xffffffff
    for ch in s:
        h = ((h ^ ord(ch)) * 3432918353) & 0xffffffff
        h = ((h << 13) | (h >> 19)) & 0xffffffff
    h = ((h ^ (h >> 16)) * 2246822507) & 0xffffffff
    h = ((h ^ (h >> 13)) * 3266489909) & 0xffffffff
    return format((h ^ (h >> 16)) & 0xffffffff, "08x")

def sign(secret, message):          # provided
    return _h(secret + "|" + message)


def verify(payload, signature, secret, timestamp, now, tolerance):
    if abs(now - timestamp) > tolerance:
        return False                          # stale → possible replay
    expected = sign(secret, str(timestamp) + "." + payload)
    if len(expected) != len(signature):
        return False
    diff = 0
    for a, b in zip(expected, signature):     # constant-time compare
        diff |= ord(a) ^ ord(b)
    return diff == 0


print(verify("body", sign("sec", "100.body"), "sec", 100, 105, 10))   # -> True
print(verify("body2", sign("sec", "100.body"), "sec", 100, 105, 10))  # -> False
Reference solution — TypeScript
function _h(s: string): string {   // provided MAC (stand-in for HMAC)
  let h = (1779033703 ^ s.length) >>> 0;
  for (let i = 0; i < s.length; i++) {
    h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  h = Math.imul(h ^ (h >>> 16), 2246822507);
  h = Math.imul(h ^ (h >>> 13), 3266489909);
  h ^= h >>> 16;
  return (h >>> 0).toString(16).padStart(8, "0");
}
function sign(secret: string, message: string): string { return _h(secret + "|" + message); }

function verify(payload: string, signature: string, secret: string, timestamp: number, now: number, tolerance: number): boolean {
  if (Math.abs(now - timestamp) > tolerance) return false;      // stale → replay
  const expected = sign(secret, timestamp + "." + payload);
  if (expected.length !== signature.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {                    // constant-time
    diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
  }
  return diff === 0;
}

console.log(verify("body", sign("sec", "100.body"), "sec", 100, 105, 10));   // -> true
console.log(verify("body2", sign("sec", "100.body"), "sec", 100, 105, 10));  // -> false

Questions about Verify a Webhook Signature

Why compare webhook signatures in constant time?

Because an ordinary string comparison can return as soon as it finds the first different character, so a guess with more correct leading characters takes slightly longer to reject. An attacker who can send many forged requests and time the responses can, in principle, use that difference to work out a valid signature one character at a time, which is far easier than guessing the whole value. A constant-time comparison always examines every character and combines the differences, so its duration does not reveal where the first mismatch was. Use the library function, such as hmac.compare_digest in Python or crypto.timingSafeEqual in Node.

Why must the signature be computed over the raw request body?

Because the sender signed exact bytes, and any change to those bytes changes the signature. Parsing the JSON and serializing it again can reorder keys, drop or add whitespace, escape characters differently or reformat numbers, so the recomputed signature fails even though the data means the same thing. Many web frameworks parse request bodies automatically, which is why webhook routes usually need the raw body captured before parsing. Verify against those bytes first, and only then parse the payload you have just authenticated.

How do timestamps prevent webhook replay attacks?

A replay attack resends a real, correctly signed request that an attacker captured earlier. The signature alone cannot stop it, because the signature is genuine. Including a timestamp in the signed message and rejecting events older than a tolerance, often a few minutes, limits how long a captured request stays usable, and because the timestamp is signed, the attacker cannot simply update it. Replays inside the window remain possible, so receivers also record recent event ids and reject duplicates, and make their handlers idempotent so a repeated event does no harm.

What is HMAC, and why not just hash the payload?

A plain hash such as SHA-256 of the body can be computed by anyone, so it only detects accidental corruption: an attacker who changes the payload simply recomputes the hash. HMAC mixes a shared secret key into the hash in a specific two-pass construction, so only someone holding the secret can produce a valid tag. Simply hashing the secret followed by the message is also unsafe with SHA-256, because that hash is vulnerable to length extension, letting an attacker append data and compute a valid tag without the key. HMAC avoids that weakness.

Finished this one? 0 / 76 Challenges done
NextPII Redactor Next on the “Integrations & messy data” ladder · rung 6 of 10

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges