Timing Attacks in Web Applications: Constant-Time Comparisons and Side-Channel Prevention

Timing side-channels in authentication and token comparison code allow attackers to brute-force secrets one byte at a time. This guide covers where they appear in Python, Node.js, Go, and Java, and how to fix them correctly.

A timing attack exploits the fact that software takes different amounts of time to run depending on the data it’s processing. In authentication code, this creates a measurable oracle: an attacker who can send many requests and measure response times can extract secret values bit by bit without ever seeing them directly. It sounds exotic but it’s a practical attack against authentication endpoints, token comparison functions, and any code that compares a secret value against user-controlled input.

The root cause is almost always the same: using a standard equality operator (==) to compare secrets. Short-circuit evaluation means the comparison returns as soon as it finds the first differing byte, leaking information about how many bytes matched. With enough measurements and statistical analysis, an attacker can recover the full secret.

Where Timing Vulnerabilities Appear

HMAC signature verification — webhook handlers that verify X-Hub-Signature or equivalent headers are the most commonly exploited instance. If you compare the expected HMAC with the provided value using ==, the response time leaks how many bytes of the HMAC matched.

API key and token comparison — any code path that compares an API key from a request against a stored value.

Password reset token validation — single-use tokens compared at redemption time.

Username enumeration — login endpoints that return faster for non-existent users than for existing users with wrong passwords (because the password hash computation is skipped). This is a timing leak that doesn’t require comparing secrets but follows the same pattern.

Session token validation — less common because session tokens are typically looked up in a database rather than compared directly, but relevant in stateless JWT alternatives.

The Vulnerable Pattern in Each Language

Python

# VULNERABLE: short-circuit equality
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return expected == signature  # timing leak here

# SAFE: constant-time comparison
import hmac

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

hmac.compare_digest has been in the Python standard library since 3.3. There is no reason to use == for secret comparison. The function works on both str and bytes but both arguments must be the same type.

Node.js

// VULNERABLE
function verifyWebhook(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return expected === signature; // timing leak
}

// SAFE
const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  
  // Both buffers must be the same length for timingSafeEqual
  const expectedBuf = Buffer.from(expected);
  const signatureBuf = Buffer.from(signature);
  
  if (expectedBuf.length !== signatureBuf.length) {
    return false; // length mismatch — reject without comparison
  }
  
  return crypto.timingSafeEqual(expectedBuf, signatureBuf);
}

Note the length check: crypto.timingSafeEqual throws if the buffers are different lengths, so you need to handle that case. The length check itself doesn’t leak information about the secret because you’re comparing against a fixed-length HMAC output.

Go

// VULNERABLE
func verifyWebhook(payload []byte, signature string, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))
    return expected == signature // timing leak
}

// SAFE
import "crypto/subtle"

func verifyWebhook(payload []byte, signature string, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := mac.Sum(nil)
    
    sig, err := hex.DecodeString(signature)
    if err != nil {
        return false
    }
    
    return subtle.ConstantTimeCompare(expected, sig) == 1
}

Go’s crypto/subtle package provides ConstantTimeCompare for byte slices and ConstantTimeEq for integers. Use them for any comparison involving secret material.

Java

// VULNERABLE
boolean verifyWebhook(byte[] payload, String signature, String secret) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
    String expected = Base64.getEncoder().encodeToString(mac.doFinal(payload));
    return expected.equals(signature); // timing leak
}

// SAFE
import java.security.MessageDigest;

boolean verifyWebhook(byte[] payload, String signature, String secret) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] expected = mac.doFinal(payload);
    byte[] provided = Base64.getDecoder().decode(signature);
    
    return MessageDigest.isEqual(expected, provided);
}

MessageDigest.isEqual performs a constant-time comparison. It’s available in Java 6+. Note that it returns true for two null arrays, so validate inputs before calling it.

Username Enumeration Timing

This variant doesn’t involve secret comparison — it’s about control flow. A login handler that skips the password hash computation for non-existent users is measurably faster for those users:

# VULNERABLE: different timing for missing vs. existing users
def login(username: str, password: str) -> bool:
    user = db.get_user(username)
    if not user:
        return False  # returns fast — no hash computation
    
    return bcrypt.checkpw(password.encode(), user.password_hash)

# SAFE: always perform the hash computation
_dummy_hash = bcrypt.hashpw(b"dummy", bcrypt.gensalt())

def login(username: str, password: str) -> bool:
    user = db.get_user(username)
    hash_to_check = user.password_hash if user else _dummy_hash
    
    result = bcrypt.checkpw(password.encode(), hash_to_check)
    return result and user is not None

The dummy hash must be computed at startup (not per-request) so the comparison time is consistent. The and user is not None ensures you don’t authenticate against the dummy hash even if checkpw somehow returns true.

Network Jitter and Remote Timing Attacks

An objection to timing attacks over the network is that jitter masks the signal. In practice, the attack works because:

  • With enough samples (typically 1,000–50,000 requests per byte), statistical methods filter out network noise
  • Local network attacks (attacker on the same LAN or cloud region) have much lower jitter
  • Cache timing effects often dwarf network jitter — a cache miss during comparison can be tens of microseconds, visible even over a noisy network

Research from 2003 (Crosby, Wallach) demonstrated remote timing attacks against OpenSSL. The attack has been reproduced against web applications many times since. Network distance does not eliminate the risk.

BREACH and Compression Oracle Attacks

A related class of attack targets HTTPS responses compressed with gzip or Brotli. BREACH (Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext) exploits the fact that if an attacker can inject known text into a compressed response and measure the response size, they can recover secret values in the same response (like CSRF tokens).

Mitigations for BREACH:

  • Disable compression for responses containing secrets (or disable per-response compression entirely)
  • Separate secrets from attacker-injectable content in response structure
  • Add random padding to responses containing secrets (CSP nonce, CSRF token)
  • Rate limit the endpoint that reveals compressed output containing secrets

BREACH requires the attacker to control reflected input in the same response as the secret, which limits its applicability. But any endpoint that reflects user input and includes a CSRF token or session identifier in the same compressed response is potentially vulnerable.

Testing for Timing Vulnerabilities

Manual testing — send requests with incorrect tokens where the first byte differs, then requests where all bytes match except the last. If median response time differs measurably (>1ms on a local network), the comparison is not constant-time.

Tools — Burp Suite’s timer, timing-attack npm package for Node.js endpoints, and the timeit approach in Python scripts can automate statistical timing measurements.

Code review — grep for == and != comparisons involving variables named token, key, signature, secret, hmac, hash, or digest. Review every instance.

# Quick grep for risky patterns in Python
grep -rn "== .*signature\|== .*token\|== .*hmac\|== .*secret" --include="*.py" .

# In JavaScript
grep -rn "=== .*signature\|=== .*token\|=== .*hmac\|=== .*secret" --include="*.js" --include="*.ts" .

Summary of Safe Functions

LanguageSafe Comparison Function
Pythonhmac.compare_digest(a, b)
Node.jscrypto.timingSafeEqual(bufA, bufB)
Gosubtle.ConstantTimeCompare(a, b)
JavaMessageDigest.isEqual(a, b)
RubyActiveSupport::SecurityUtils.secure_compare(a, b)
PHPhash_equals($expected, $provided)

The pattern is consistent across languages: use the platform-provided constant-time comparison function, never the equality operator, for any comparison where one of the values is a secret. It takes one additional line to use the safe version. There is no performance argument for using == on secrets.