CWE-208: Observable Timing Discrepancy - Python
Overview
In Python, timing discrepancies typically come from comparing secrets - password hashes, HMAC digests, API keys, session tokens - with the standard ==/!= operators on str or bytes, which compare lengths first and then stop at the first mismatch. Use hmac.compare_digest(a, b) from the standard library for any comparison involving a secret; its duration does not vary with how much of the input matched. Most higher-level libraries (HMAC verification helpers, JWT libraries, password hashing libraries) already use constant-time comparison internally, so the risk is concentrated in hand-written HMAC/signature verification, webhook signature checks, and custom API key or token validation.
Pass it bytes, not str. compare_digest accepts two str arguments only while both are pure ASCII: give it a str containing any character above U+007F and it raises TypeError: comparing strings with non-ASCII characters is not supported. That is not a theoretical input. WSGI decodes request headers as latin-1, so a single 0xFF byte in a header is enough, and the result is a 500 where a wrong-but-ASCII value returns 401 - an observable discrepancy in the response itself, on the CWE about observable discrepancies. Encode both sides, or hash both sides, before comparing.
The largest timing discrepancy on a typical Python web application is not in any comparison, though - it is a login that looks the user up before hashing anything. See Django below, and check for it first when triaging a finding on an authentication endpoint.
Common Vulnerable Patterns
Manual API Token Comparison
# VULNERABLE - == is not constant-time: it compares lengths, then stops at
# the first difference
def verify_api_key(provided_key: str, stored_key: str) -> bool:
return provided_key == stored_key
# Attack: submit many candidate keys of the right length, measure response
# latency per attempt
# Result: a candidate that matches further into the key takes marginally longer
# to reject, and one of the wrong length is rejected faster still
Why this is vulnerable: CPython compares the two lengths, then hands the characters to memcmp, which stops at the first difference, so both the length and roughly where the difference falls are reflected in how long the call takes. How much they leak is worth measuring rather than assuming: on CPython 3.13 with a 64-character key compared in-process, a mismatch in the first character took 22.1 ns and one in the last 24.2 ns, and the intermediate positions came in eight-byte plateaus - positions 3 and 12 differed by 0.3 ns, which is below the noise of the measurement. The per-character recovery this is usually described as does not follow from those numbers, but the length signal is real (a length mismatch answered in 19.2 ns, faster than any content mismatch), memcmp behaviour is a libc detail rather than a language guarantee, and the replacement below costs nothing.
Webhook Signature Verification with ==
# VULNERABLE - == on bytes is not constant-time
import hashlib
import hmac as hmac_module # aliased only to avoid shadowing in this example
def compute_signature(payload: bytes, secret: bytes) -> bytes:
return hmac_module.new(secret, payload, hashlib.sha256).digest()
def verify_webhook_signature(payload: bytes, signature_header: bytes, secret: bytes) -> bool:
computed = compute_signature(payload, secret)
return computed == signature_header # DANGEROUS!
Why this is vulnerable: == on bytes compares lengths and then calls memcmp, which stops at the first difference, so an attacker who can resend a payload with a guessed signature learns something about where the guess went wrong. The early exit is unmistakable at scale - measured on CPython 3.13, a difference in the first byte of a one-megabyte value was answered in 26.1 ns against 29,611 ns for one in the last byte - but on a 32-byte digest the whole spread collapses to a couple of nanoseconds, for the same reason as the API key above. Treat it as a reason to fix it cheaply rather than as grounds to dismiss it: memcmp is a libc implementation detail, and a signature comparison is the one place where paying for a constant-time primitive is unarguable.
Secure Patterns
hmac.compare_digest
# SECURE - constant-time comparison regardless of where a mismatch occurs
import hmac
import hashlib
def verify_api_key(provided_key: str, stored_key: str) -> bool:
# Hash both sides: compare_digest raises TypeError on a str holding any
# non-ASCII character, and a digest is 32 bytes whatever the key's length.
provided = hashlib.sha256(provided_key.encode("utf-8")).digest()
expected = hashlib.sha256(stored_key.encode("utf-8")).digest()
return hmac.compare_digest(provided, expected)
def compute_signature(payload: bytes, secret: bytes) -> bytes:
return hmac.new(secret, payload, hashlib.sha256).digest()
def verify_webhook_signature(payload: bytes, signature_header: bytes, secret: bytes) -> bool:
computed = compute_signature(payload, secret)
return hmac.compare_digest(computed, signature_header)
Why this works: hmac.compare_digest() runs its loop to completion whatever the two values contain, so its running time depends on their length and never on how much of the content matched - there is no early exit for an attacker's timing measurement to key off. It is also available as secrets.compare_digest() - the same function object, re-exported from the secrets module since Python 3.6 - if that import better fits the module's existing conventions.
The webhook function takes bytes on both sides already, which is why it can pass them straight through. verify_api_key takes str, so it hashes first: compare_digest('\xff\xfe', 'anything') raises TypeError rather than returning False, and an API key arriving from a request header is exactly where a non-ASCII byte turns up. Hashing also fixes the width at 32 bytes, so a submitted key of the wrong length cannot be told apart from one of the right length with the wrong contents. Verified on CPython 3.13: the correct key returns True, and a wrong key, a two-character prefix of the right key and '\xff\xfe' all return False without raising.
Framework-Specific Guidance
Django
# SECURE - Django's built-in constant-time comparison utility
from django.utils.crypto import constant_time_compare
def verify_csrf_or_api_token(provided_token: str, expected_token: str) -> bool:
return constant_time_compare(provided_token, expected_token)
Why this works: django.utils.crypto.constant_time_compare() calls secrets.compare_digest() on both arguments after running each through force_bytes(), so it is the idiomatic choice inside Django code and is also more forgiving than the raw primitive: because it encodes first, it returns False for the non-ASCII str that makes compare_digest raise TypeError, and it compares a str against an equivalent bytes rather than rejecting the pair. Verified on Django 6.1. Prefer it over a hand-written comparison in views, middleware, or custom authentication backends.
Django's check_password() already performs a safe comparison, and django.contrib.auth.backends.ModelBackend also runs a throwaway hash when the username does not exist, through check_password_with_timing_attack_mitigation() - so a login-timing finding against a project using the stock backend is a false positive. Django also gets an ordering right that two other frameworks get wrong, which is worth knowing if you move between them: its authenticate() reads check_password_with_timing_attack_mitigation(user, password) and self.user_can_authenticate(user), so the hash runs before the is_active check and a disabled account costs the same as an enabled one. Spring's DaoAuthenticationProvider and ASP.NET Core Identity both run their account-state checks first and answer in microseconds for a disabled, locked or unconfirmed account - see the Java and C# pages for the measurements. A custom authentication backend loses that protection unless it does the same thing, and that is where the large discrepancy shows up: hashing costs hundreds of milliseconds and the branch that skips it costs microseconds. CWE-287 has the measured before-and-after for both Django and Flask.
Flask / FastAPI API Key Decorators
# SECURE - API key check in a Flask decorator
import hashlib
import hmac
from functools import wraps
from flask import request, abort
def require_api_key(expected_key: str):
expected_digest = hashlib.sha256(expected_key.encode("utf-8")).digest()
def decorator(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
# WSGI hands header values over as latin-1 decoded str, so
# encode("latin-1") recovers the bytes the client actually sent.
provided = request.headers.get("X-API-Key", "").encode("latin-1")
if not hmac.compare_digest(hashlib.sha256(provided).digest(), expected_digest):
abort(401)
return view_func(*args, **kwargs)
return wrapped
return decorator
Why this works: Custom decorators and dependency functions are the code most likely to hand-roll a comparison outside a framework's built-in authentication path, since Flask and FastAPI don't ship an opinionated API-key mechanism. Routing the check through hmac.compare_digest() closes the same timing channel a == check would leave open, and defaulting the header to "" avoids a TypeError from a missing header while still failing the comparison safely.
Passing the header straight to compare_digest as a str is the version that breaks, and it breaks on exactly the input an attacker supplies. Reproduced against a live server on Flask 3.1.3 and Werkzeug 3.1.8: a header of one 0xFF byte followed by ASCII returned 500, where a wrong-but-ASCII key returned 401. The 500 is worse than the timing channel it sits next to - it is an oracle a client can read off the status line without measuring anything, and the request never reaches the comparison at all. Encoding to latin-1 and hashing removes both the exception and the length signal; verified with the same server, a correct key returns 200 and a wrong ASCII key, a 0xFF prefix, a header of 32 high bytes, an empty header and a prefix of the correct key all return 401.
Considerations
Confirm the compared value is actually a secret. This weakness is about
comparisons an attacker can time their way through, which means the value has
to be one they are trying to guess: a password hash, an HMAC digest, an API
key, a session token, a signature. == on a username, a public identifier or a
feature flag is not this finding, however much it looks like the flagged
pattern. Record those as false positives with the reason.
Check whether the library already did it. Password verification helpers in the major frameworks compare in constant time internally, so a comparison of the boolean result they return is not a timing leak and does not need changing. The finding is about your own comparison of raw secret bytes.
Decide whether leaking the length matters. Unlike .NET's FixedTimeEquals
and Node's timingSafeEqual, compare_digest neither requires equal lengths
nor rejects unequal ones early, so no length pre-check is needed and writing
one would add a leak the helper does not have. What it does have is a loop
bounded by the second argument - measured on CPython 3.13, comparing 1 byte
against 4096 took 2,280 ns and 4096 against 1 took 75 ns. Pass the submitted
value first and the secret second, so the duration follows data the attacker
already knows rather than announcing the secret's length. For fixed-size values
(a SHA-256 digest, a signature) the length is public anyway. For
variable-length secrets such as API keys, hash both sides first and compare the
digests, which fixes the width whichever way round the arguments go.
Testing
- A correct API key, token and webhook signature still authenticate. Without that accept case, a fix that rejects everything passes the suite exactly as a working one does.
- A header carrying a byte above
0x7Freturns401, not500. Send it as raw bytes rather than through a client library that will reject or re-encode it:X-API-Key: \xffis enough, and a500means astris still reachingcompare_digest. - A request that would put a
stron one side of the comparison andbyteson the other is answered401, not500.compare_digestraisesTypeError: a bytes-like object is required, not 'str'on that pair, so reaching it at all means the code is still relying on its callers to have encoded consistently. - Time three logins - unknown username, known username with a wrong password, known username with the right password - and assert the first two are within noise of each other. A sub-millisecond answer for the unknown username is the enumeration oracle, and no re-scan can see it.
- Search the codebase for other
==/!=comparisons against a hash, token, key or secret field - the reported line is a sample, not the population.
Common Pitfalls
- Converting bytes to a hex or Base64 string and comparing those with
==, on the theory that encoding avoids the issue - encoding does not change the comparison semantics;==on the encoded string is exactly as vulnerable as==on the raw bytes. - Writing a custom constant-time loop with manual XOR accumulation instead of using
hmac.compare_digest()- a hand-written loop is easy to get subtly wrong (e.g. an earlyreturnslipped back in during a later edit) and the standard library implementation is already correct, reviewed, and maintained. - Catching the
TypeErrorfromcompare_digest()and treating it as a mismatch - it has two causes and neither is one. Astragainst abytesmeans one side was never encoded, which is a bug in your code; astrholding a character above U+007F is attacker-supplied input reaching the primitive undecoded. Catching it turns both into a silentFalseand leaves the second one able to skip the comparison entirely. Encode or hash both sides explicitly instead, or use Django'sconstant_time_compare(), which does it for you.