The idea: the time a check takes can tell what it found
A server that checks a secret (an API key, a session token, a password-reset code, the MAC tag on a cookie or a webhook) answers yes or no. The answer itself gives nothing away. But if the check finishes sooner for some wrong guesses than for others, its running time is a second answer. This is a timing side channel, and the attack that reads it is a timing attack.
How to use the animation
The server's secret token is K7Q2. Pick a guess and press Send Guess. The request passes the rate limiter and is handed to two versions of the check, which compare one character per step, side by side. Each request adds a bar to each version's chart: its height is the work done, with the toy cost model 1 tick = 1 character compared. The Options group turns on a rate limit.
How an early-exit compare works
The obvious way to compare two strings stops as soon as it finds a difference. That is what == on strings, strcmp, memcmp and Arrays.equals do, because it is the fastest way to get the answer:
def check_v1(t):
for i in range(4):
if t[i] != S[i]:
return False # stops at the first difference
return True
A guess whose first character is wrong costs 1 tick. A guess with the first two characters right costs 3. So the time says how long the correct prefix is (Demo: early exit leaks the matching prefix). Characters after the first wrong one are never looked at, so they don't change the time at all (Demo: only the first wrong character counts).
This matters because it splits one huge search into small ones. A 4-character token over 36 symbols has 364 ≈ 1.7 million values; if each character can be confirmed on its own, it takes at most 36 × 4 = 144 tries. For a real 32-character token the gap is between "never" and "a few thousand requests". On a real network one tick is a few nanoseconds lost in noise, but averaging many measurements makes small differences visible, so a small leak is still a leak.
The fix: always compare every byte
A constant-time compare looks at every character, whatever it finds, and makes one decision at the end. It XORs each pair (0 when they are equal) and ORs the results together:
def check_v2(t):
diff = 0
for i in range(4):
diff |= ord(t[i]) ^ ord(S[i]) # no branch, no early exit
return diff == 0
The work is the same for every guess of the right length: 4 ticks for ZZZZ, 4 for K7QZ, 4 for the right token (Demo: one wrong guess, both checks, Demo: the right token). There is no branch that depends on the secret, so there is nothing to measure. Don't write this loop yourself: compilers can turn it back into an early exit. Use the library function, which is written and tested to stay constant time.
| Language | Constant-time compare | Notes |
|---|---|---|
| Python | hmac.compare_digest(a, b) | bytes or ASCII str; also secrets.compare_digest |
| Node.js | crypto.timingSafeEqual(a, b) | Buffers of equal length; throws if the lengths differ, so compare fixed-length digests |
| Java | MessageDigest.isEqual(a, b) | byte arrays; constant time since Java 6u17 |
| Go | subtle.ConstantTimeCompare(a, b) | returns 1 if equal; hmac.Equal for MACs |
| PHP | hash_equals($known, $user) | known string first |
| .NET | CryptographicOperations.FixedTimeEquals | spans of bytes |
| C (OpenSSL) | CRYPTO_memcmp(a, b, n) | never memcmp for secrets |
Verifying an HMAC or a token
The most common place for this bug is MAC verification. A server signs a cookie, a webhook body or a download link with HMAC(key, message); to check a request it recomputes the tag and compares it with the one that came in. If that comparison is ==, an attacker can learn the right tag for a forged message byte by byte, without ever knowing the key. The fix is one call:
expected = hmac.new(key, body, hashlib.sha256).digest()
if not hmac.compare_digest(expected, received_tag):
return 403
For stored tokens such as API keys or reset codes, a good pattern is to store only a hash of the token and compare the hash of the incoming one. Both sides then have the same fixed length, and even a leaky compare would only leak bytes of a hash.
Good practice
- Use the library's constant-time compare for anything secret: MAC tags, tokens, API keys, password-reset codes, CSRF tokens.
- Use the library's verify functions for passwords (
bcrypt.checkpw, argon2verify) and signatures (a JWT library's verify): they compare safely for you. See Password Hashing. - Compare fixed-length values, e.g. two SHA-256 digests or two HMAC tags, so the length doesn't leak either.
- Rate-limit and lock out failed attempts per client and per account (Demo: rate limit stops repeated guessing). A timing attack needs many measurements; a few tries are not enough. See Rate Limiting Windows.
- Use long random secrets from a CSPRNG (
secrets.token_urlsafe(32)), and don't rely on random delays: noise is averaged away by repeating the measurement.
See also OWASP Integrity Failures for HMAC-signed cookies, OWASP Authentication Failures for brute force and lockout, and OWASP Cryptographic Failures.
What the page leaves out
The cost model counts only character compares; real time also depends on caches, branch prediction, the network and the server's load, which is why real measurements need statistics. The page does not cover other side channels (cache timing on table lookups in ciphers, power use, error messages that differ), timing leaks in database lookups of a token, the length leak of comparing strings of different length, or constant-time code for cryptographic algorithms themselves.