0x55aa
← Back to Blog

Timing Attacks: Why Your === Is Leaking Secrets ⏱️

6 min read

Timing Attacks: Why Your === Is Leaking Secrets ⏱️

Picture this: you write if (token === userToken) { ... }. Looks fine. Reviewed by three senior devs. Shipped to production. You sleep soundly.

Then someone drains your users' accounts by measuring how long your server takes to say "nope."

Welcome to timing attacks — the vulnerability that makes developers go "that can't actually work in real life" right up until it does.

What Even Is a Timing Attack? 🤔

Your CPU isn't equally slow for everything. When === compares two strings, most implementations bail out early the moment they find a mismatching character:

"secret123"  vs  "aaaaaaaaa"  → mismatch at index 0 → returns instantly
"secret123"  vs  "secret999"  → mismatch at index 6 → takes slightly longer
"secret123"  vs  "secret123"  → full match          → takes the longest

That difference is nanoseconds. Sounds undetectable, right?

Wrong. Send the same guess 10,000 times and average the response times. The statistical noise collapses, and the signal becomes crystal clear. An attacker can enumerate the correct secret one character at a time, reducing a brute-force from billions of attempts to a few thousand.

This isn't theoretical. It's been used to bypass HMAC validation in production APIs, bypass API key checks, and extract session tokens from web apps.

The Vulnerable Pattern (Probably In Your Codebase Right Now) 💀

Here's what "obvious" token validation looks like:

// Node.js - DON'T DO THIS
app.post('/webhook', (req, res) => {
  const providedSecret = req.headers['x-webhook-secret'];
  const expectedSecret = process.env.WEBHOOK_SECRET;

  // This leaks timing information!
  if (providedSecret !== expectedSecret) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Process webhook...
  processWebhook(req.body);
  res.json({ ok: true });
});

The !== comparison exits early on the first mismatching character. An attacker targeting your webhook endpoint can measure the response time delta across thousands of requests and figure out your secret character by character.

The same problem exists in PHP, Python, Ruby — any language that does naive string comparison.

The Fix: Constant-Time Comparison ✅

The solution is a comparison that always takes the same amount of time regardless of where the strings diverge. Node.js ships this in the crypto module:

const crypto = require('crypto');

app.post('/webhook', (req, res) => {
  const providedSecret = req.headers['x-webhook-secret'];
  const expectedSecret = process.env.WEBHOOK_SECRET;

  // Both inputs must be the same byte length for timingSafeEqual
  const provided = Buffer.from(providedSecret ?? '', 'utf8');
  const expected = Buffer.from(expectedSecret, 'utf8');

  // Guard against length mismatch (also a timing leak!)
  if (provided.length !== expected.length) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const isValid = crypto.timingSafeEqual(provided, expected);

  if (!isValid) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  processWebhook(req.body);
  res.json({ ok: true });
});

Every popular language has an equivalent:

Language Safe Function
Node.js crypto.timingSafeEqual()
Python hmac.compare_digest()
PHP hash_equals()
Ruby ActiveSupport::SecurityUtils.secure_compare()
Go subtle.ConstantTimeCompare()
Rust subtle::ConstantTimeEq (subtle crate)

Wait, Does This Actually Work in the Real World? 🌐

Fair skepticism. A few things that make timing attacks harder over the internet:

  • Network jitter: Variable latency adds noise
  • Server load: Other requests affect response times
  • TLS overhead: Encryption adds unpredictable timing

But here's the thing: modern timing attacks don't need millisecond precision. With enough samples (10k–100k requests), statistical analysis filters out the noise. Researchers have demonstrated successful timing attacks over cross-continental internet connections.

For local networks, private APIs, or any situation where the attacker has low-latency access? Timing attacks are practically trivial.

And with serverless functions that have predictable cold-start behavior, or co-located cloud services? The attack surface just got a lot friendlier for attackers.

The HMAC Shortcut That Actually Prevents All This 🔐

For webhooks specifically, the best practice isn't just safe comparison — it's using HMACs (Hash-based Message Authentication Codes). GitHub, Stripe, Shopify all do this:

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  // Compute expected HMAC over the raw request body
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(payload) // payload = raw Buffer from req.body
    .digest('hex');

  const expected = Buffer.from(`sha256=${expectedSig}`, 'utf8');
  const provided = Buffer.from(signature, 'utf8');

  if (expected.length !== provided.length) return false;

  // Safe compare the HMAC digests, not the secret itself
  return crypto.timingSafeEqual(expected, provided);
}

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-hub-signature-256'];

  if (!verifyWebhookSignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  res.json({ ok: true });
});

The beauty here: even if an attacker extracts the HMAC digest via timing, they still can't reverse-engineer your secret. The HMAC is computed fresh each request, so it changes with every different payload. Game over for the attacker.

Other Places Timing Attacks Hide 🔍

Webhooks are just the poster child. Look out for timing vulnerabilities in:

Password reset tokens — comparing tokens with === before checking expiry leaks token structure.

API key validation — especially in middleware that checks keys against a database or in-memory map.

2FA codes — a TOTP code comparison done with === is a timing oracle. Yes, TOTP codes rotate every 30 seconds, but that's still enough time for a local attacker.

Cache-based side channels — if your validation logic hits the database for valid keys but returns early for invalid format, response time differences reveal structural information about valid key format.

Cryptographic MAC verification — the classic attack that broke early TLS implementations (Lucky13, BEAST, POODLE all had timing components).

Practical Checklist: Audit Your App Now 📋

Run through your codebase and find every place you compare:

  • API keys and tokens
  • Webhook secrets
  • Password reset tokens (though you should use timing-safe hashed comparison anyway)
  • CSRF tokens
  • Any === or == comparison involving a secret value

For each one, swap in the constant-time equivalent. It takes ten minutes, and you'll sleep better.

A quick grep to find potential issues in a Node.js project:

# Find === comparisons near words that suggest secrets
grep -rn "=== .*[Tt]oken\|=== .*[Ss]ecret\|=== .*[Kk]ey\|=== .*[Ss]ig" src/

Not a perfect audit, but a good starting point to find the obvious offenders.

The Bottom Line ⚡

Timing attacks sound academic until you realize the fix is literally one function call. crypto.timingSafeEqual(), hmac.compare_digest(), hash_equals() — pick your language's version and use it everywhere you compare secrets.

The cost: zero performance impact (constant-time comparison is still fast). The benefit: you close an entire class of side-channel attacks that have broken real production systems.

The next time you write if (token === req.headers['x-api-key']), your brain should immediately scream "timing attack." That reflex is worth more than any security scanner.


Found a timing vulnerability in the wild? Connect with me on LinkedIn — I love hearing real-world war stories from security-aware developers.

More security deep-dives on GitHub — because the best code is code that doesn't make headlines for the wrong reasons. 🔐