A timing attack is a side-channel attack where an attacker infers secrets by measuring the time a system takes to execute cryptographic operations. One of the most common and overlooked timing vulnerabilities occurs in string comparisons.
Standard comparison operators (such as == or === in JavaScript, == in Python, or strcmp in C) are optimized for speed: they return false at the first mismatched byte. This early-exit optimization leaks information about the prefix of the secret being compared. Over a network, an attacker can use statistical analysis to determine a secret token or signature byte-by-byte.
The Mechanics of Early-Exit Comparisons
To understand the leak, consider how a standard character-by-character string comparison function behaves. Below is a conceptual illustration of an early-exit loop:
function vulnerableCompare(input, secret) {
if (input.length !== secret.length) {
return false;
}
for (let i = 0; i < input.length; i++) {
if (input[i] !== secret[i]) {
return false; // Early exit
}
}
return true;
}
If the attacker sends a guess where the first character is incorrect, the loop terminates after one iteration. If the first character is correct but the second is wrong, it terminates after two iterations.
Each additional iteration takes a small, measurable amount of time (due to instruction execution, cache hits/misses, and branch prediction).
Attacker Guess: "x..." -> Mismatch at index 0 -> Loop runs 1 time -> Time: T
Attacker Guess: "a..." -> Mismatch at index 0 -> Loop runs 1 time -> Time: T
Attacker Guess: "s..." -> Match at index 0, mismatch at index 1 -> Loop runs 2 times -> Time: T + dt
By measuring this difference ($dt$), an attacker can deduce that the first character of the secret is “s”. The attacker then moves to the second character (“sa…”, “sb…”, “sc…”), and continues until the entire secret is recovered.
Overcoming Network Jitter
Developers often assume that nanosecond-scale differences cannot be measured over the internet due to network latency fluctuations (jitter). This is a dangerous misconception.
Network jitter follows statistical distributions. An attacker can overcome this noise by sending thousands of requests for each guess and calculating the minimum response time (or the lower 10th percentile). The minimum response time represents the case with the least network interference, isolating the internal execution time of the target server.
In 2010, security researchers demonstrated that timing differences as small as 20 nanoseconds could be reliably detected over local networks, and sub-microsecond differences could be exploited over the public internet.
Constant-Time Comparison Algorithms
To prevent timing side-channels, cryptographic comparisons must run in constant time. The execution duration must be independent of the values being compared. This is achieved by:
- Avoiding conditional branches based on input values.
- Evaluating every single byte of the input, regardless of mismatches.
- Using bitwise operations to accumulate differences.
A pure JavaScript constant-time comparison function looks like this:
function constantTimeCompare(a, b) {
// Length check must be constant-time or handled before comparison
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
// XOR bytes: returns 0 if bytes match, non-zero if they differ
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
// If result is 0, all bytes matched
return result === 0;
}
The Length Leakage Problem
The function above still returns early if a.length !== b.length. This leaks the exact length of the secret. Furthermore, native platforms can enforce strict constraints on buffer sizes.
For example, Node.js’s native crypto.timingSafeEqual throws an error if the input buffers do not have identical lengths:
const crypto = require('crypto');
// Throws: RangeError: Inputs must have the same length
crypto.timingSafeEqual(Buffer.from('guess'), Buffer.from('longersecret'));
To safely compare inputs of arbitrary length without leaking the secret’s length, you must hash both values using a cryptographic hash function (like SHA-256) first, and then compare the resulting digests. Since hashes of the same algorithm always produce fixed-length outputs (e.g., 32 bytes for SHA-256), they can be safely passed to a constant-time comparison function.
$$\text{digest}{\text{input}} = \text{HMAC-SHA256}(K, \text{input})$$ $$\text{digest}{\text{secret}} = \text{HMAC-SHA256}(K, \text{secret})$$ $$\text{match} = \text{constantTimeCompare}(\text{digest}{\text{input}}, \text{digest}{\text{secret}})$$
Note: Using an HMAC instead of a simple hash prevents length-extension attacks and adds a layer of resistance against offline preimage lookups.
Code Implementations
Node.js (Server-Side)
Here is a secure API token verification implementation using Node.js’s native crypto module:
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET_API_KEY = process.env.API_KEY || "super-secret-token";
function verifyApiKey(clientKey) {
const secretBuffer = Buffer.from(SECRET_API_KEY, 'utf8');
const inputBuffer = Buffer.from(clientKey, 'utf8');
// Use HMAC to generate constant-length tokens to prevent length leaks
const hmacKey = crypto.randomBytes(32); // Random key per request
const expectedHash = crypto.createHmac('sha256', hmacKey)
.update(secretBuffer)
.digest();
const inputHash = crypto.createHmac('sha256', hmacKey)
.update(inputBuffer)
.digest();
// crypto.timingSafeEqual runs in constant time
return crypto.timingSafeEqual(inputHash, expectedHash);
}
app.get('/api/data', (req, res) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey || !verifyApiKey(apiKey)) {
return res.status(401).send('Unauthorized');
}
res.json({ sensitiveData: "Engine room telemetry" });
});
app.listen(3000);
Browser / Web Crypto API
In client-side environments (such as Cloudflare Workers or browser extensions), you can achieve constant-time comparison by comparing cryptographic signatures or manually using typed arrays:
/**
* Safely compares two Uint8Arrays in constant time.
*/
function timingSafeEqual(a, b) {
if (a.byteLength !== b.byteLength) {
return false;
}
let result = 0;
for (let i = 0; i < a.byteLength; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
// Example usage checking a hash digest
async function verifySignature(clientSig, actualMessage, secretKey) {
const encoder = new TextEncoder();
// Import raw key
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secretKey),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
// Generate actual HMAC
const actualSigBuffer = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(actualMessage)
);
const actualSigArray = new Uint8Array(actualSigBuffer);
const clientSigArray = new Uint8Array(clientSig);
// Compare digests in constant-time
return timingSafeEqual(clientSigArray, actualSigArray);
}
By ensuring that secret comparisons always execute in constant time, you eliminate the timing side-channel entirely, protecting sensitive credentials and signatures from remote recovery.