Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Verify a Webhook Signature
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.
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.
Implement verify(...): check the timestamp window first, recompute sign(secret, "{timestamp}.{payload}"), and constant-time-compare it against the received signature.
Examples and constraints 4 examples
payload = "body", signature = sign("sec", "100.body"), secret = "sec", timestamp = 100, now = 105, tolerance = 10Truepayload = "body2", signature = sign("sec", "100.body"), secret = "sec", timestamp = 100, now = 105, tolerance = 10Falsepayload = "body", signature = sign("sec", "100.body"), secret = "sec", timestamp = 100, now = 200, tolerance = 10Falsepayload = "x", signature = sign("s", "50.x"), secret = "s", timestamp = 50, now = 60, tolerance = 10True- 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
nowby more thantolerance.
Hints 4 ideas
- Bail out early only on the timestamp check: if
abs(now - timestamp) > tolerance, return False. - Recompute the expected signature with the provided
signoverstr(timestamp) + "." + payload. - Constant-time compare: if lengths differ return False; otherwise XOR each pair of characters into an accumulator and require it to be 0.
- This mirrors how Stripe/GitHub webhooks work — real code just swaps
signfor 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.
| step | check | value | outcome |
|---|---|---|---|
| 1 | freshness | abs(105 - 100) = 5, not above 10 | continue |
| 2 | expected | sign("sec", "100.body2") = "a1f318cd" | — |
| 3 | received | "394d9ecb", signed over "100.body" | — |
| 4 | lengths | 8 and 8 | continue |
| 5 | positions 0 to 3 | XORs 82, 8, 82, 87 | diff = 95 |
| 6 | positions 4 to 7 | XORs 8, 93, 0, 6; the loop still visits every one | diff stays 95 |
| 7 | result | diff == 0 is false | return 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, whilenow = 61→False. The window is inclusive: only a gap greater thantoleranceis rejected.timestamp = 50, now = 30, tolerance = 10→False.absalso 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,zipcompares only the overlapping characters,diffstays 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(), thenhmac.compare_digest(expected, signature). In Node,crypto.createHmac("sha256", secret).update(message).digest("hex"), thencrypto.timingSafeEqualon two Buffers; it throws aRangeErrorwhen 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
nowandtimestamp, 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)) # -> FalseReference 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)); // -> falseQuestions 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.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Retry with Exponential BackoffTools time out and APIs rate-limit — a production agent retries without hammering. Exponential backoff waits longer after each failure (1s, 2s, 4s, 8s…) up to a cap. Compute the delay schedule: delay[i] = min(cap, base·2ⁱ). Solve it in Python or TypeScript.Read →
- Idempotency Key HandlerNetworks retry. A webhook fires twice; a payment is re-sent after a timeout. Without idempotency you double-charge or double-write. The fix: dedupe by a client-supplied key and always return the first result. Solve it in Python or TypeScript, with hidden tests.Read →
- CSV → Schema MapperEvery customer hands you a messy export. Before it flows into your system, each row must be coerced to a schema — strings to ints, "true" to booleans — and the rows that don’t fit quarantined, not silently dropped. Solve it in Python or TypeScript, with hidden tests.Read →
- Data Reconciliation DiffThe customer swears the migration worked. You reconcile: compare source records against what landed in the target, keyed by id, and report exactly what was added, removed, or changed. Solve it in Python or TypeScript, with hidden tests.Read →
- PII RedactorBefore customer data touches a log line — or a third-party LLM — the personal bits have to go. Mask emails, SSNs, credit cards, and phone numbers with pattern matching, leaving the rest readable. Solve it in Python or TypeScript, with hidden tests.Read →
- Config ValidatorHalf of "it doesn’t work at the customer" is a bad config — a missing key, a string where an int belongs, an env that isn’t allowed. Validate it up front and fail loudly with a clear list instead of crashing three layers deep. Solve it in Python or TypeScript, with hidden tests.Read →