An ordinary equality check on a secret, such as an API key, a webhook signature or a reset token, can tell an attacker how close their guess is simply by how long the check takes. Application timing leaks are not exotic: CWE lists real products that compared HMAC signatures with a plain string equals, and login pages that answered faster for unknown usernames. This guide covers the application layer only: comparing tokens, HMACs and password hashes safely, removing timing differences from login and reset flows, how realistic remote measurement is, and how to find and test for these flaws. It is written for application developers, AppSec engineers and code reviewers. CPU, cache and physical side channels are covered in the Side-Channel Attacks Guide, and this page does not repeat them.
Key takeaways
- Never compare secrets with ==. Use the platform's constant-time function: hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js, MessageDigest.isEqual in Java, CryptographicOperations.FixedTimeEquals in .NET, subtle.ConstantTimeCompare in Go, hash_equals in PHP.
- Length still leaks. Every one of those functions documents that a length mismatch is handled early. Compare fixed-length values (digests), not raw variable-length secrets.
- The comparison is rarely the only leak. Database lookups by token, early returns for unknown users and skipped password hashing leak more time than a byte comparison ever will.
- Remote measurement is practical. Concurrency-based "timeless" timing attacks published at USENIX Security 2020 detected differences as small as 100 nanoseconds over the network, so "the internet is too noisy" is not a defence.
- Find it with code review and lint rules, prove it with statistics. Static rules catch equality on secret-named variables; a fixed test of many timed requests per case confirms that login and reset paths are flat.
How application timing leaks work
A timing leak exists when two code paths that should look identical to an outsider take measurably different time, and the difference depends on a secret. CWE classifies this as CWE-208 Observable Timing Discrepancy, a child of CWE-203 Observable Discrepancy. At the application layer there are three common shapes.
- Early-exit comparison: a byte-by-byte equality check stops at the first mismatch. The more leading bytes of a guess are correct, the longer the check runs, so an attacker can in principle recover a secret one position at a time instead of guessing the whole value. CWE-208 cites CVE-2019-10071, where a framework compared HMAC signatures with String.equals() rather than a constant-time comparison.
- Branch on existence: the code returns as soon as it learns that a user, email or record does not exist, and only does expensive work (password hashing, sending an email, writing an audit row) when it does. CWE-208 lists several username discovery cases of this kind, including CVE-2003-0637 and CVE-2004-1602.
- Secret-dependent lookups: searching a database, cache or map by the secret itself. The lookup cost can vary with how much of the key matches, and the result branch (found or not found) usually adds a much larger difference.
The defender's goal is not to make code fast or slow. It is to make the time an outsider can observe independent of the secret. That is the same principle the Side-Channel Attacks Guide applies to cryptographic libraries, applied here to ordinary request handling.
Where these leaks appear in real applications
| Location | Secret at risk | Typical leaky pattern | Safe pattern |
|---|---|---|---|
| Webhook receivers | Shared HMAC signing key (via forged signatures) | Computed signature compared to the header with == | Recompute HMAC, compare digests with a constant-time function; GitHub's webhook docs explicitly warn against == |
| API key and bearer token checks | The key or token | Key used directly as a database lookup value, then compared with == | Look up by a non-secret identifier, then constant-time compare a stored hash of the secret part |
| Session cookies and signed URLs | Signature or MAC | Custom signature check written by hand | Framework's signing helper, or constant-time compare of the MAC |
| Password login | Whether an account exists | Return early when the username is not found; hash only for real users | Run the same hash work on every request, including a dummy hash for unknown users |
| Password reset and registration | Whether an email is registered | Send email synchronously only for known accounts | Same response and same timing for every request; queue the send asynchronously |
| MFA and one-time codes | The code | String equality on the submitted code | Constant-time compare plus strict attempt limits |
| Internal service-to-service auth | Shared secrets, client secrets | Static secret compared with == in middleware | Constant-time compare, or replace shared secrets with mTLS or signed tokens |
Many of these secrets belong to machines rather than people: API keys, webhook signing keys and client secrets are non-human identity credentials, and a timing leak against them is a credential exposure. See the API Key Management Guide and the Webhook HMAC Signing vs mTLS vs IP Allowlisting guide for the wider controls around those credentials.
Constant-time comparison functions by language
Every mainstream platform ships a comparison function designed not to stop at the first differing byte. The documented behaviour, checked against each project's own documentation in November 2026, differs mainly in how it treats inputs of different length.
| Platform | Function | Input types | Different lengths | Documented caveat |
|---|---|---|---|---|
| Python (3.3+) | hmac.compare_digest(a, b) | Both ASCII str, or both bytes-like | Returns False; length and type may leak, values do not | Mixing str and bytes is an error; uses OpenSSL CRYPTO_memcmp where available from 3.10 |
| Node.js (v6.6.0+) | crypto.timingSafeEqual(a, b) | Buffer, TypedArray, DataView, ArrayBuffer | Throws an error | Does not make surrounding code timing safe; float arrays can give unexpected results |
| Java | java.security.MessageDigest.isEqual(a, b) | byte[] | Returns false | Time depends only on the length of the first argument |
| .NET | CryptographicOperations.FixedTimeEquals(left, right) | ReadOnlySpan<byte> | Returns false immediately (the only short circuit) | Use instead of SequenceEqual for MAC tags |
| Go | crypto/subtle.ConstantTimeCompare(x, y) | []byte | Returns 0 immediately | Run time depends on length, not contents |
| PHP (5.6+) | hash_equals($known, $user) | string | Returns false immediately; known length may leak | The user-supplied value must be the second argument |
| Ruby on Rails | ActiveSupport::SecurityUtils.secure_compare / fixed_length_secure_compare | String | secure_compare returns false; fixed_length_secure_compare raises | Secret length can be determined |
- Compare digests, not raw secrets: because length is handled early everywhere, compare values of a fixed length. For HMAC verification this is natural: both sides are digests of the same algorithm. For a stored token, store a hash of it and compare the hash of the submitted value with the stored hash. Hashing first also removes the Node.js exception path for mismatched lengths.
- Normalise encoding before comparing: decode hex or base64 to bytes on both sides, or encode both the same way. Comparing a lowercase hex string with an uppercase one, or a "sha256=" prefixed header with a bare digest, makes a correct signature fail and tempts developers back to hand-written comparisons.
- Prefer the higher-level helper: a framework's signed cookie, CSRF or token verification helper already compares safely. Hand-written verification code is where most findings come from.
- Do not write your own loop: an XOR-accumulate loop looks constant time in source code but compilers and JIT runtimes can optimise it. Go's documentation is explicit that its data-independent timing mode only helps code that is already written with constant-time operations. Use the library function.
Django's constant_time_compare lives in django.utils.crypto, which Django's documentation does not list among its stable public utilities, so check the source for your version or use hmac.compare_digest directly.
Removing timing differences from login, reset and lookup flows
The comparison function only protects the last few microseconds. The bigger differences come from what the request does before and after it. The Account Enumeration Prevention Guide covers response wording, status codes and bot controls; this section covers the timing side.
- Login: the OWASP Authentication Cheat Sheet shows the classic "quick exit" flaw, where an unknown user returns before any password hashing, and notes the difference can reach seconds with slow hashes. Run the same work on every path: when the account is not found, verify the submitted password against a fixed dummy hash created with the same algorithm and parameters as real hashes. Keep that dummy hash's parameters in step when you raise work factors, or the gap returns. See the Password Hashing and Key Derivation Guide.
- Password reset and sign up: the OWASP Forgot Password Cheat Sheet asks for a consistent message and a consistent response time for existing and non-existent accounts, using asynchronous calls or identical logic. In practice, put the "send email" job on a queue for every request and let the worker discard requests for unknown addresses.
- Token lookup: do not query the database by the full secret. Split the token into a non-secret selector used for the lookup and a secret verifier whose hash is compared in constant time. The lookup then reveals only whether a selector exists, which an attacker cannot use to learn the verifier.
- Locked and disabled accounts: evaluate lockout and disabled status after the credential work, not before, so these states do not return faster than a wrong password.
- Error handling: exceptions are slow. A decode error on a malformed signature should take the same route as a well-formed wrong signature, ending in one generic rejection.
- Artificial delay: a random sleep adds noise that averaging removes, so it is not a fix on its own. Padding every response to a fixed minimum duration can hide residual differences but costs latency and capacity; use it only where the work genuinely cannot be equalised.
How realistic remote timing attacks are
Teams often dismiss timing findings because network jitter dwarfs a few nanoseconds of comparison time. That assumption is weaker than it was.
- Statistics beat jitter: jitter is noise, and noise averages out over many measurements. An attacker who can send many requests can detect differences far smaller than a single request's variance. Rate limits therefore help directly, which is why the OWASP Authentication Cheat Sheet treats brute-force protection as part of enumeration defence.
- Concurrency removes much of the noise: the "Timeless Timing Attacks" paper (Van Goethem, Pöpper, Joosen and Vanhoef, USENIX Security 2020) sent requests so they arrive together, via packet coalescing and concurrent handling, and read which response came back first. The authors reported roughly a 100-fold improvement over typical internet timing attacks and detected differences as small as 100 nanoseconds, against HTTP/2 web servers, Tor onion services and EAP-pwd.
- Co-located attackers are closer still: a tenant in the same cloud region or a compromised service inside your network sees far less jitter than an internet attacker.
- Size of difference matters: a byte-comparison leak is small and needs many requests; a skipped password hash is milliseconds to seconds and can be seen in a handful of requests. Fix the large differences first.
Choosing the right fix
| Situation | Preferred approach | Trade-off |
|---|---|---|
| Verifying a MAC or signature | Library verify function, or constant-time compare of fixed-length digests | None worth noting; this should be universal |
| Checking a stored API key or token | Selector plus hashed verifier, constant-time compare of hashes | Requires a token format change and migration of existing keys |
| Login for unknown users | Dummy hash verification on every miss | Spends hashing capacity on attack traffic; pair with rate limiting |
| Reset and registration emails | Asynchronous queue for every request | Adds a worker and queue to operate |
| Residual differences that cannot be equalised | Pad to a fixed minimum response time | Latency cost on every request; fails if work exceeds the floor |
| Legacy shared secrets between services | Replace with mTLS or signed short-lived tokens | Larger project, but removes the static secret entirely |
Small organisations: the highest value step is a one-off search for == and equals() on secret-named variables, plus using framework helpers. Large organisations: add a lint rule to CI so the pattern cannot return, publish a shared verification library per language, and include timing checks in authentication test suites.
Implementation and testing
- Inventory secret comparisons: search code for comparisons involving names such as signature, hmac, token, apikey, secret, otp and digest, and for webhook and middleware verification code. Static analysis tools such as the Semgrep Guide and CodeQL Guide cover custom rules for this pattern.
- Replace comparisons: swap each for the platform constant-time function, comparing decoded fixed-length bytes. Keep the change small and reviewable; the behaviour for correct inputs should not change.
- Fix flow-level leaks: add dummy hashing on unknown users, move email sending to a queue, and move token lookups to a selector.
- Test statistically: in a test environment close to production, send a large fixed number of interleaved requests for each case (valid user with wrong password, unknown user, locked user; correct prefix versus wrong prefix for a token) and compare the distributions, not single timings. Interleaving cases cancels drift from load and caching. A visible gap in medians or a significant distribution test is a failure.
- Guard in CI: enable the lint rule as blocking for new code once existing findings are fixed, and keep the timing tests for login and reset in the regression suite.
- Roll back safely: comparison changes are low risk, but token format changes are not. Support old and new token formats in parallel during migration, with the old path still using a constant-time compare, and retire the old format on a dated plan.
Detection and measurement
Timing probes look like high volumes of near-identical requests: many login attempts against varied usernames with similar passwords, many reset requests for different addresses, or bursts of requests with signatures that share a prefix. Log authentication failures with a reason code (internally only, never in the response), source, and target, and alert on volume and on concurrent bursts from one client. HTTP/2 connections carrying many simultaneous requests to an authentication endpoint deserve particular attention given the concurrency research above.
| Metric | What good looks like |
|---|---|
| Open static analysis findings for non-constant-time secret comparison | Zero, with the rule blocking in CI |
| Median response time gap, known versus unknown user (login, reset, sign up) | Within normal run-to-run variation in the timing test |
| Share of API keys and tokens using selector plus hashed verifier | Rising to 100% on a dated plan |
| Authentication endpoints covered by per-account and per-source rate limits | 100% |
| Timing regression tests in the authentication suite | Present and passing on every release |
Common failure patterns
- Constant-time compare on a variable-length raw secret: the length leaks by design of every library function, and in Node.js the mismatch throws an exception that takes a different path. Compare hashes.
- Safe compare after a leaky lookup: the token is found by querying the database for the full value, then compared safely. The query already leaked whether it exists.
- Argument order mistakes: PHP's hash_equals expects the known value first and the user value second, and Java's MessageDigest.isEqual timing depends on the first argument's length. Swapping them weakens the guarantee.
- Dummy hash out of date: real hashes move to a higher work factor or Argon2id but the dummy hash for unknown users still uses old parameters, reopening a measurable gap.
- Fast exit for locked accounts: lockout checked before the password work, so locked accounts answer faster than active ones and reveal both existence and lock status.
- Hand-rolled "constant-time" loops: a custom comparison that the compiler or JIT optimises back into an early exit. Use the platform function.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| OWASP ASVS 5.0 | V11.2.4 | Cryptographic operations, including comparisons, must be constant time with no short-circuit operations |
| OWASP ASVS 5.0 | V6.3.8 | Valid users must not be deducible from failed authentication, including from response times |
| CWE | CWE-208, CWE-203, CWE-204 | Observable timing discrepancy; observable discrepancy; observable response discrepancy |
| MITRE CAPEC | CAPEC-462 Cross-Domain Search Timing; CAPEC-112 Brute Force | Attack patterns that timing leaks enable or accelerate |
| NIST SP 800-53 Rev 5 | SC-13, IA-6, AC-7, SA-11 | Cryptographic protection; authentication feedback; unsuccessful logon attempts; developer testing |
| NIST CSF 2.0 | PR.PS-06 | Secure software development practices integrated into the life cycle |
| MITRE D3FEND | D3-MAN Message Authentication | Verifying MACs safely is part of implementing message authentication; see the D3-MAN Message Authentication guide |
Practitioner checklist
- Search every codebase for == or equals() on signatures, tokens, API keys, OTPs and digests, and replace each with the platform constant-time function.
- Compare decoded, fixed-length digests rather than raw secrets, and put the known value first where the library requires it.
- Store hashes of API keys and tokens, look them up by a non-secret selector, and compare the verifier hash in constant time.
- Run dummy password verification for unknown users with the same algorithm and parameters as real hashes, and update it when parameters change.
- Queue reset and verification emails for every request so known and unknown addresses respond identically.
- Check lockout and disabled status after credential work, and send all failures down one generic rejection path.
- Apply per-account and per-source rate limits to every authentication and verification endpoint.
- Add a blocking lint rule and statistical timing tests for login and reset to CI.
Standards and references
- MITRE: CWE-208 Observable Timing Discrepancy
- OWASP: Authentication Cheat Sheet
- OWASP: Forgot Password Cheat Sheet
- Python Software Foundation: hmac module documentation
- Node.js: Crypto API documentation
- Microsoft Learn: CryptographicOperations.FixedTimeEquals
- GitHub Docs: Validating webhook deliveries
- USENIX: Timeless Timing Attacks, Exploiting Concurrency to Leak Secrets over Remote Connections
Related NHI Mgmt Group resources: Side-Channel Attacks Guide · Account Enumeration Prevention Guide · Password Hashing and Key Derivation Guide · AES-GCM vs ChaCha20-Poly1305