Timing attack: an API token check that returns early

A token check that compares byte by byte and returns at the first mismatch is open to a timing attack, because it takes longer when more leading bytes are right. In our local fixture that difference was about 1.3 nanoseconds in-process, and 25 samples per guess could not detect it over loopback HTTP. With a stated artificial delay of 2 ms per matching byte, 1,600 timed requests recovered the first four characters of a synthetic token, and the same attack against hmac.compare_digest found nothing.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

What a timing attack on a token check leaks

A timing side channel is information that leaks through how long an operation takes. Here the operation compares a supplied API token with the stored one. CWE-208, Observable Timing Discrepancy, covers the weakness: two operations take different amounts of time, an actor can observe the difference, and it reveals something about the product's state.

The vulnerable pattern is a comparison that stops at the first wrong byte. This is the function in the fixture, including the artificial sleep that we explain below:

def insecure_equals(supplied, expected, delay_s):
    if len(supplied) != len(expected):
        return False
    for got, want in zip(supplied, expected):
        if got != want:
            return False  # early return: time depends on how many bytes matched
        if delay_s:
            time.sleep(delay_s)  # artificial per-byte cost, stated in the post
    return True

An attacker who can send many requests tries each possible first character and keeps the slowest. They then fix that character and repeat for the second. For a 16-character hex token this takes at most 16 x 16 = 256 distinct guesses, compared with 2^64 for blind guessing. Each guess needs many samples, because one request is noisy. A constant-time comparison, whose duration does not depend on where the inputs differ, removes the signal.

Scope of the fixture

  • Target: one short-lived HTTP server on 127.0.0.1, one endpoint, GET /api/v1/me, which returns 200 for the right bearer token and 401 otherwise.
  • Token: 16 lowercase hex characters, synthetic, generated at random for each run. The recorded run used 4b0266e94967baa9. It has no value outside this fixture.
  • Artificial delay: the vulnerable server sleeps 2.0 ms after each matching byte. We added it on purpose, because the early-return gap of a 16-character comparison is too small for HTTP timing on loopback. The no-delay run below shows this.

Only test systems you own or have written permission to test; the Acceptable Use Policy describes authorized use. For the permission side of API testing, see authorization testing for APIs.

Reproduce the run

Download fixture.py and verify.py into one directory. You need Python 3.9 or later (standard library only) and curl. Then run:

python3 verify.py --output rerun

The output directory must be new. The runner times three comparison functions in-process, sends eight curl control requests, and runs a timing attack with Python's http.client on one keep-alive connection. For each of four token positions it sends 25 requests for each of 16 candidate characters, interleaved so drift hits every candidate equally, and picks the candidate with the highest median latency. It then restarts the vulnerable server with no delay and attacks again.

The recorded command was python3 verify.py --output recorded-run. It printed {"checks": 12, "passed": true, "failures": []}. The run started at 2026-11-10 22:47 UTC on macOS arm64 with Python 3.15.2 and curl 8.7.1. Timings depend on the machine and change between runs. The 12 checks cover the controls, the vulnerable attack and the fixed retest. The no-delay runs are observations only.

Observed results

Comparison functions in-process. We compared two 32-byte strings and two 65,536-byte strings with ==, hmac.compare_digest and a pure-Python early-return loop. Each cell is the median of 21 rounds, and each round averaged 100,000 calls (2,000 for 65,536 bytes). Values are nanoseconds per call.

Median time per call in nanoseconds, in-process
FunctionLengthMismatch at first byteMismatch at last byteEqual
==32 B11.2312.5512.47
hmac.compare_digest32 B29.5330.9829.75
Python loop, early return32 B117.75528.88529.76
==64 KiB9.831,210.711,212.21
hmac.compare_digest64 KiB18,701.5618,907.5818,970.15

The pure-Python loop is 4.5 times slower when the mismatch is at the end, so the method can see a real early-return effect. For == on 32 bytes the gap is 1.3 ns, and the interquartile ranges of the 21 round values do not overlap (10.90 to 12.17 ns against 12.40 to 13.08 ns). The difference is real but tiny. One HTTP request on the fixed server took a median of about 0.06 to 0.08 ms, roughly 50,000 times longer. At 64 KiB the effect is large (9.83 ns against 1,210.71 ns), but API tokens are short. hmac.compare_digest still shows a 1.4 ns gap at 32 bytes. We did not find the cause. At 64 KiB its medians differ by 206 ns on 18,700 ns, with overlapping interquartile ranges.

HTTP timing attack with the artificial delay. The attacker starts with nothing and builds the token one character at a time, padding guesses with z, which is not a hex character. Latencies are medians of 25 requests per candidate.

Vulnerable mode with 2.0 ms delay per matching byte, port 53587
PositionTrue characterPickedMedian of picked (ms)Median of other 15 (ms)Margin (ms)
1442.6280.0992.529
2bb5.1822.6602.521
3007.7605.2252.534
42210.1147.7142.400

The attack recovered 4b02, all four true characters. Each correct byte adds about 2.5 ms, more than the 2.0 ms we asked for, probably because sleep overshoots. The data is noisy. At position 1, the 375 requests for wrong candidates had a median of 0.099 ms, a 95th percentile of 0.127 ms and a maximum of 1.916 ms. The 25 requests for the right candidate ranged from 2.412 to 2.681 ms. Single spikes happen, so one request per guess is not enough. Medians over 25 requests separated the candidates with room to spare. A real network adds more jitter.

The same attack with no artificial delay. We restarted the vulnerable server with --delay-ms 0, so the comparison is the plain early-return loop. The blind attack returned a7b3 for the true prefix 4b02, with no position right. With the true prefix supplied, it picked b, d, 6 and 2, and only the last is correct. The margins were 0.002 to 0.003 ms, the size of the jitter, and the true character ranked 16th, 10th, 2nd and 1st of 16. One right pick in four is consistent with chance (1 in 16 per position). In this run, natural early-return timing over loopback HTTP gave no usable signal.

Controls. The curl requests behaved the same in both modes: the right token returned 200, a wrong token returned 401, the right first eight characters followed by zzzzzzzz returned 401, and a request with no token returned 401. The right token works and near misses fail.

Verify a timing finding before you report it

  • Show the comparison is variable-time. Read the code or time the function in-process, as in the first table. A gap that clears the interquartile range is evidence. A gap inside the noise is not.
  • Measure from the attacker's position. Interleave candidates, take medians and keep the raw samples. Report the margin next to the spread (here 2.4 to 2.5 ms against a 95th percentile of 0.127 ms for wrong guesses at position 1).
  • Start with a token you own. Check that the right next character ranks first before you claim a blind recovery.
  • Run the controls. A valid token must work and a near miss must fail.
  • State the limits. Record the request count, the network path and any rate limit. Call a difference you cannot separate from jitter an observation. A rate limit changes how many samples an attacker can send; see API rate limiting.

Use a constant-time comparison and retest

The fixed mode of the same source replaces the early return with hmac.compare_digest:

def token_ok(mode, supplied, expected, delay_s):
    if mode == "fixed":
        return hmac.compare_digest(supplied, expected)
    return insecure_equals(supplied, expected, delay_s)

The Python documentation for hmac.compare_digest says the function "uses an approach designed to prevent timing analysis by avoiding content-based short circuiting behaviour". Both arguments must have the same type: ASCII str or a bytes-like object. The documentation adds that a length mismatch could reveal the types and lengths of the inputs, but not their values. It also says that since Python 3.10 the function uses OpenSSL's CRYPTO_memcmp() when available. Our server passes bytes.

We started the fixed server with the same --delay-ms 2.0 option, which fixed mode ignores. The only difference from the first attack is the comparison function. We ran the attack twice.

Fixed mode, same attack, port 53593
RunPickedTrue prefixCorrect positionsLargest margin (ms)True character rank
Blind0e9d4b020 of 40.00511, 9, 7, 10 of 16
True prefix givena1444b020 of 40.0086, 8, 8, 7 of 16

No candidate stood out by more than 0.008 ms, which is 8 microseconds, against 2.4 ms or more in the vulnerable run. The true character ranked between 6th and 11th, with no pattern. The curl controls still passed: 200 for the right token, 401 for the three bad requests. The retest shows the compared bytes no longer change the measured time at this resolution. The time may still vary below it.

Evidence bundle

All data is synthetic.

What this experiment does not cover

This is one machine, one run and one keep-alive connection on loopback, with no TLS, proxy or concurrent load. The 2.0 ms delay is artificial, so the HTTP result shows that the method works when the leak is large. It does not show that a real server using plain == is exploitable. The no-delay run gave no signal with 25 samples per candidate. More samples or a longer comparison could change that; we did not test them.

We attacked four characters of a 16-character token. We did not test other runtimes, token hashing before comparison, rate limiting or the length leak the Python documentation describes. The fixture is a toy and sets no production severity.

Excession Labs runs this kind of secret-comparison check in its security assessments. See how a finding like this looks in a sample report.