October 17, 2026
$12,000 for Listening to How Long ‘No’ Takes: A Timing Attack That Recovered a Secret API Key
Most authentication bypasses I’ve found involve getting a system to accept something it shouldn’t. This one never bypassed anything in that…

By T4nv1
7 min read
Most authentication bypasses I've found involve getting a system to accept something it shouldn't. This one never bypassed anything in that sense — every single guess I sent was correctly rejected, every time, with the application behaving exactly as its developers intended at every step. The vulnerability wasn't in what the server told me. It was in how long it took to tell me, and across tens of thousands of carefully measured rejections, that timing alone was enough to reconstruct a secret value one character at a time.
The target, which I'll call Windmere here per their program's disclosure terms, is a B2B platform offering a webhook-signing feature — outgoing webhook payloads are signed with a secret key shared between Windmere and the receiving integration, and the receiving endpoint verifies incoming signatures by comparing them against its own locally stored copy of that same secret.
Why String Comparison Is Rarely as Simple as It Looks
Comparing two strings for equality is one of the most ordinary operations in programming, and most general-purpose string comparison functions are written to be fast, which in practice usually means they return as soon as they find the first mismatched character, rather than always checking every character regardless of where a difference occurs. For almost every use of string comparison, that's a completely reasonable, harmless optimization. It becomes a genuine vulnerability specifically when one of the two strings being compared is a secret, and an attacker can both control the other string and precisely measure how long the comparison took — because an early-exit comparison takes measurably less time to reject a guess that's wrong in its very first character than one that happens to correctly guess several leading characters before diverging, and that time difference, while often tiny, can be statistically recovered across enough repeated measurements.
Windmere's webhook signature verification endpoint, reachable as a legitimate feature allowing customers to test their own webhook integration's signature-checking logic against a sample payload, took a submitted signature value and compared it against a correctly computed one server-side, returning a simple valid/invalid response.
The First Signal
I sent two deliberately different, entirely wrong signature guesses and measured response time precisely using repeated requests and averaging, to smooth out ordinary network jitter:
Guess A: "0000000000000000000000000000000000000000000000000000000000000000"
Guess B: "a000000000000000000000000000000000000000000000000000000000000000"Guess A: "0000000000000000000000000000000000000000000000000000000000000000"
Guess B: "a000000000000000000000000000000000000000000000000000000000000000"Both wrong. Both rejected. If the comparison was implemented using a genuinely constant-time method — one that always examines the full length of both values regardless of where they first differ, specifically to prevent exactly the kind of attack I was testing for — I'd expect no meaningful timing difference between these two guesses at all, since both are wrong starting from the very first character. Averaged across five hundred repeated requests per guess to reduce noise, Guess A and Guess B showed no statistically meaningful difference — consistent with a properly constant-time implementation, at least so far.
The more targeted test came next: trying guesses where I varied only the first character across the full possible range, while holding everything else constant, and looking for any guess whose average response time stood out from the rest, however slightly.
"0000...0000" (first char: 0)
"1000...0000" (first char: 1)
"2000...0000" (first char: 2)
...
"f000...0000" (first char: f)"0000...0000" (first char: 0)
"1000...0000" (first char: 1)
"2000...0000" (first char: 2)
...
"f000...0000" (first char: f)Across five hundred repetitions each, sixteen possible first-character values (the signature was hex-encoded), one value's average response time came out measurably — if only slightly — higher than the rest: roughly 0.3 milliseconds slower, consistently, across repeated full test runs. A difference that small is easy to dismiss as noise from a single measurement, but consistent across hundreds of repetitions and multiple independent retestings of the full sixteen-character sweep, it was a real, reproducible signal rather than random variation — strongly suggesting the comparison function was, despite my first test's inconclusive result, not actually constant-time after all, just subtle enough in its timing difference that it required a more statistically rigorous testing approach than my first, simpler test to detect.
Building the Full Character-by-Character Extraction
Having found one leaking character position, the rest of the attack was a matter of patient repetition. For each position in the secret, left to right, I held every already-confirmed character fixed, varied only the current position across its full sixteen possible hex values, measured average response time across several hundred repetitions per value at that position, and took the slowest-responding value as the correct character for that position — the logic being that a comparison correctly matching one additional character before finally diverging takes, on average, marginally longer than one that diverges immediately, assuming the underlying comparison implementation does in fact exit early on the first mismatch.
discovered_secret = ""
for position in range(signature_length):
timings = {}
for candidate_char in "0123456789abcdef":
guess = discovered_secret + candidate_char + ("0" * (signature_length - position - 1))
timings[candidate_char] = measure_average_response_time(guess, repetitions=500)
discovered_secret += max(timings, key=timings.get)discovered_secret = ""
for position in range(signature_length):
timings = {}
for candidate_char in "0123456789abcdef":
guess = discovered_secret + candidate_char + ("0" * (signature_length - position - 1))
timings[candidate_char] = measure_average_response_time(guess, repetitions=500)
discovered_secret += max(timings, key=timings.get)This is, deliberately, an enormously noisy and slow process — each additional character requires several thousand total requests to measure reliably, and the timing differences involved are small enough that network jitter, server load variation, and general measurement noise all meaningfully interfere with getting a clean signal. I ran this extraction process over several hours, with repeated retesting of ambiguous positions where the timing signal wasn't clearly decisive on the first pass, before arriving at a complete, sixty-four-character candidate secret value.
Confirming the Extracted Secret Was Actually Correct
Timing-attack extraction is probabilistic by nature, and a wrong character anywhere in a sixty-four-character secret would mean the whole reconstructed value is useless. I confirmed correctness the only way that genuinely proves it: using the fully reconstructed secret to generate a valid webhook signature for a test payload myself, matching the exact HMAC construction Windmere's own documentation described for legitimate integrations, and submitting that self-generated, correctly-signed payload to the verification endpoint. It validated successfully — conclusive proof the extracted value was the genuine secret, not a plausible-looking but ultimately incorrect guess.
Scope and Responsible Extraction
I conducted this entire extraction against a webhook secret generated specifically for my own test account — Windmere's platform allowed generating and regenerating this secret freely for testing purposes, which meant I never needed to target any value belonging to a real customer integration at all. I want to be explicit that the technique, once proven against my own secret, generalizes directly to any other account's secret under identical conditions; I did not attempt it against any other account's value, since doing so would mean actually extracting a real secret belonging to someone else rather than demonstrating that the underlying mechanism works.
The Report
Timing attack findings carry an unusual burden of proof compared to most vulnerability classes, since the "evidence" is inherently statistical rather than a single clean request/response pair — the report needs to make the methodology and its reliability clear enough that a triager without a statistics background can still trust the conclusion.
Title: Timing side-channel in webhook signature verification allows full secret key extraction via non-constant-time string comparison
Root cause: The webhook signature verification endpoint compares a submitted signature against the correct, server-computed value using a comparison method that exits upon encountering the first mismatched character, rather than a constant-time comparison that always examines every character regardless of where a difference occurs. This produces a statistically measurable, reproducible timing difference correlated with how many leading characters of a guess correctly match the real secret, enabling full character-by-character extraction through repeated, carefully measured requests.
Reproduction: The full extraction methodology, the averaging and repetition approach used to overcome measurement noise, the complete extracted secret value, and — critically — confirmation that the extracted value was genuinely correct via successful use in generating a validating signature, with explicit documentation that extraction was performed solely against a secret generated for my own test account.
Impact: An attacker with sufficient network access and patience can fully extract any account's webhook signing secret through this endpoint alone, without needing any other vulnerability, credential, or prior access — compromising the integrity guarantee the signature mechanism exists specifically to provide, since an attacker possessing the real secret can forge arbitrary, validly-signed webhook payloads.
Fix recommendations:
- Replace the comparison with a genuinely constant-time comparison function (most languages and cryptography libraries provide one specifically for this purpose —
hmac.compare_digestin Python,crypto.timingSafeEqualin Node.js, and equivalents elsewhere), which always examines the full length of both values regardless of where they first diverge - Rate-limit the signature verification endpoint aggressively, which independently raises the practical cost of this kind of statistical extraction attack even if a future comparison implementation reintroduces a timing leak
- Rotate the webhook signing secret for any account where a timing-vulnerable comparison was in production, since the theoretical extractability demonstrated here means a sufficiently patient attacker could have already succeeded against a real account's secret before this report
What Happened After
Windmere treated this with real seriousness once the full extraction methodology and its successful confirmation were understood — a secret fully recoverable through nothing but careful timing measurement is a striking enough demonstration that it tends to move fast through triage regardless of how statistically unusual the evidence looks compared to a typical report. They replaced the comparison function with a constant-time equivalent within four days and added rate limiting to the verification endpoint as defense in depth shortly after. Their resolution notes mentioned rotating signing secrets platform-wide as a precaution, given the inability to rule out prior exploitation with full confidence. The report closed at $12,000, rated critical, reflecting complete compromise of the signature mechanism's security guarantee achievable through legitimate-looking traffic alone.
Why Timing Attacks Reward Statistical Patience Over Clever Payloads
This bug class doesn't reward a clever single request the way most injection-style vulnerabilities do — it rewards methodical, repeated measurement and genuine comfort with the idea that your evidence is a statistical pattern across thousands of data points rather than one clean, demonstrable response. A few habits worth carrying into your own testing:
Any endpoint comparing a submitted secret, token, or signature against a stored value is worth testing for timing leakage, especially authentication tokens, API keys, webhook signatures, and password-reset tokens — anywhere exact-match comparison against a secret happens server-side.
Always average many repetitions per guess, never trust a single timing measurement. Network jitter and server load variation routinely produce larger differences than the timing leak itself on any single request; the signal only emerges reliably across hundreds of repetitions per candidate value.
Confirm extracted secrets through actual, independent use, not just by trusting the statistical process. A timing attack's output is a best guess, not a certainty, until verified by successfully using the extracted value exactly as a legitimate credential would be used.
Limit real extraction attempts to your own test credentials wherever a platform allows generating one. The technique generalizing to other accounts' secrets is the finding; actually extracting someone else's real secret is a line a responsible proof of concept doesn't need to cross to make that point completely.
What stayed with me longest about this one is how little the application ever did wrong in any single interaction. Every guess I sent was correctly rejected. Every response looked, to any ordinary monitoring or logging system, exactly like routine, unremarkable traffic. The entire vulnerability lived in a gap measured in fractions of a millisecond, invisible to anything except thousands of carefully averaged measurements — a secret recovered not by ever being told it, but by listening closely enough to how long it took, every single time, to be told no.