Timing Attacks in Web Authentication: How Response Time Leaks Valid Usernames and Tokens

Timing attacks exploit measurable differences in server response times to extract information — confirming valid usernames, breaking HMAC verification, and enumerating API keys. This guide covers the attack mechanics, which authentication flows are vulnerable, and the constant-time comparison patterns that close the leak.

Timing attacks are a class of side-channel attack where the attacker learns information not from the data returned by a system, but from how long the system takes to respond. In web authentication contexts, these attacks are typically used to enumerate valid usernames, bypass token verification, or confirm the existence of accounts — without triggering rate limits or account lockout because no invalid credentials are being submitted.

They’re subtle, they’re real, and the fix is almost always a one-line change that developers consistently overlook.

How the Attack Works

The classic timing attack against login flows exploits the difference in execution time between “this username doesn’t exist” and “this username exists but the password is wrong.”

Consider a typical login handler:

def login(username, password):
    user = db.find_user(username)
    if user is None:
        return {"error": "Invalid credentials"}
    if not check_password(user.password_hash, password):
        return {"error": "Invalid credentials"}
    return {"token": generate_session_token()}

The database query db.find_user(username) returns quickly when the user doesn’t exist (a miss in the index) and slightly longer when the user does exist and the row must be loaded. The check_password function runs bcrypt or argon2, which is deliberately slow (100ms+). When the user doesn’t exist, that slow password check is skipped entirely.

The result: requests for nonexistent usernames return in ~2ms; requests for existing usernames return in ~100ms. An attacker making thousands of requests to the login endpoint can identify which usernames are valid with statistical confidence — even though both paths return the same error message.

Measuring the Leak

An attacker does not need specialised hardware or physical access. A simple script with high-resolution timing and enough requests to average out network jitter is sufficient:

import requests
import time
import statistics

def measure_login_time(username, password, samples=50):
    times = []
    for _ in range(samples):
        start = time.perf_counter()
        requests.post("https://target.com/api/login", 
                     json={"username": username, "password": password},
                     timeout=5)
        elapsed = time.perf_counter() - start
        times.append(elapsed)
    return statistics.mean(times)

# Measure a known-invalid username vs a suspected valid one
baseline = measure_login_time("definitely_not_a_real_user_xyzxyz", "anypassword")
suspect  = measure_login_time("admin", "anypassword")

print(f"Baseline: {baseline*1000:.1f}ms")
print(f"Suspect:  {suspect*1000:.1f}ms")
print(f"Difference: {(suspect - baseline)*1000:.1f}ms")

On a low-latency connection, a 50-100ms timing difference is easily distinguishable even with network jitter. Over a high-latency connection, you need more samples — but the attack remains feasible.

HMAC Token Verification

A more serious timing leak exists in HMAC verification when developers use regular string equality instead of constant-time comparison.

# Vulnerable: standard == comparison short-circuits on first mismatch
def verify_webhook(payload, signature, secret):
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return signature == expected  # BUG: early exit reveals matching prefix length

Python’s == operator on strings exits as soon as it finds a mismatched character. A 64-character HMAC hex string that starts with aa takes longer to reject than one that starts with zz — because the comparison has to reach the first differing character before stopping.

An attacker who can control the request signature and send thousands of requests can use timing differences to learn the HMAC value one character at a time. This is the classic HMAC timing oracle.

The Fix: Constant-Time Comparison

Every language has a constant-time comparison function. Use it whenever comparing security-sensitive values.

Python:

import hmac

# Correct: hmac.compare_digest runs in constant time regardless of where strings differ
def verify_webhook(payload, signature, secret):
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

Node.js:

const crypto = require('crypto');

// Correct: crypto.timingSafeEqual runs in constant time
function verifyWebhook(payload, signature, secret) {
    const expected = crypto
        .createHmac('sha256', secret)
        .update(payload)
        .digest();
    const provided = Buffer.from(signature, 'hex');
    
    // Both buffers must be the same length or timingSafeEqual throws
    if (provided.length !== expected.length) return false;
    return crypto.timingSafeEqual(expected, provided);
}

Go:

import "crypto/subtle"

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

Ruby:

require 'rack'

# Correct: Rack::Utils.secure_compare is constant-time
def verify_webhook(payload, signature, secret)
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
  Rack::Utils.secure_compare(signature, expected)
end

Username Enumeration: The Dummy Hash Approach

The fix for username-timing leaks requires running the password hashing operation even when the user doesn’t exist, so both code paths take the same amount of time.

import bcrypt

# Generate a dummy hash at startup — store in configuration, not computed per request
DUMMY_HASH = bcrypt.hashpw(b"dummy-password-for-timing-protection", bcrypt.gensalt())

def login(username, password):
    user = db.find_user(username)
    
    if user is None:
        # Run bcrypt anyway to prevent timing difference
        # The result is intentionally discarded
        bcrypt.checkpw(password.encode(), DUMMY_HASH)
        return {"error": "Invalid credentials"}
    
    if not bcrypt.checkpw(password.encode(), user.password_hash):
        return {"error": "Invalid credentials"}
    
    return {"token": generate_session_token()}

The dummy hash must be pre-computed (not generated per request) and must use the same algorithm and cost factor as real password hashes. Generating it on every failed request would itself create a timing difference.

Password Reset Token Comparison

Password reset and magic link flows are another common location for timing leaks. Reset tokens are typically stored in the database and compared against a user-supplied value:

// Vulnerable: === performs early-exit string comparison
async function validateResetToken(token) {
    const record = await db.findResetToken(token.substring(0, 8)); // lookup by prefix
    if (!record) return null;
    if (record.token === token) {  // BUG: timing leak
        return record.userId;
    }
    return null;
}

// Fixed: use timing-safe comparison for the full token
const crypto = require('crypto');

async function validateResetToken(token) {
    const record = await db.findResetToken(token.substring(0, 8));
    if (!record) return null;
    
    const tokenBuf = Buffer.from(token);
    const storedBuf = Buffer.from(record.token);
    
    if (tokenBuf.length !== storedBuf.length) return null;
    if (!crypto.timingSafeEqual(tokenBuf, storedBuf)) return null;
    
    return record.userId;
}

API Key Validation

APIs that accept API keys in headers are vulnerable to the same pattern. Comparing an inbound key against a stored value using standard equality is exploitable:

# Vulnerable
def validate_api_key(key):
    stored_key = get_stored_key(key[:8])  # lookup prefix for DB efficiency
    return stored_key == key  # BUG: timing leak on full key comparison

# Fixed
def validate_api_key(key):
    stored_key = get_stored_key(key[:8])
    if stored_key is None:
        # Compare against dummy to prevent timing difference on prefix miss
        hmac.compare_digest(key, "0" * len(key))
        return False
    return hmac.compare_digest(stored_key, key)

Testing for Timing Vulnerabilities

Burp Suite: The Burp Suite Pro “Repeater” with timing analysis and the Turbo Intruder extension both support high-precision response time measurement. The timing_attack Python package provides a scriptable alternative.

Manual baseline: Establish response time baselines for known-invalid and known-valid usernames, then run statistical significance testing (t-test with 50+ samples per condition). A statistically significant difference exceeding 5ms is a finding worth reporting.

Code review: grep for == in authentication-adjacent code that compares string values arriving from external sources. This will over-flag, but it’s a fast first pass.

The One-Liner Rule

If you take one thing from this guide: any time you compare a security-sensitive value that arrived from an external source against a stored or computed value, use your language’s constant-time comparison function. The performance overhead is negligible. The security benefit is concrete and the attack it prevents is well-documented and regularly exploited.