Skip to content

CWE-208: Observable Timing Discrepancy - JavaScript

Overview

In Node.js, timing discrepancies typically come from comparing secrets - password hashes, HMAC digests, API keys, session tokens - with ===/== or Buffer.equals(), neither of which is constant-time. Use crypto.timingSafeEqual(a, b) from the built-in node:crypto module for any comparison involving a secret. It requires both Buffer/TypedArray arguments to be the same byte length and throws a RangeError otherwise, so length must be handled separately from the comparison itself. This is Node.js-specific; browser-side JavaScript has no equivalent, and browser code should never be comparing server-held secrets client-side in the first place. Libraries like jsonwebtoken already handle signature comparison internally, so the risk is concentrated in hand-written HMAC/signature verification, webhook signature checks, and custom API key or token validation.

The largest timing discrepancy on a typical Express application is not in any of those comparisons, though - it is a login that looks the user up before hashing anything. See Passport.js / Session Token Checks below, and check for it first when triaging a finding on an authentication endpoint.

Common Vulnerable Patterns

Manual API Token Comparison

// VULNERABLE - strict equality is not constant-time: it compares lengths,
// then stops at the first difference
function verifyApiKey(providedKey, storedKey) {
  return providedKey === storedKey;
}

// Attack: submit many candidate keys of the right length, measure response
// latency per attempt
// Result: a candidate that matches further into the key takes marginally longer
// to reject, and one of the wrong length is rejected faster still

Why this is vulnerable: V8 compares the two lengths, then compares the characters and stops at the first difference, so both the length and roughly where the difference falls are reflected in how long the call takes. How much they leak is worth measuring rather than assuming: on Node 24.3 with a 64-character key compared in-process, every position from the first character to the last fell between 5.7 ns and 8.0 ns, and positions 3 and 12 came out identical - so V8 is plainly not comparing one character per step. The per-character recovery this is usually described as does not follow from those numbers, but the length signal is real, the comparison strategy is a V8 implementation detail rather than a language guarantee, and the replacement below costs nothing.

Webhook Signature Verification with Buffer.equals

// VULNERABLE - Buffer.equals() is not constant-time
const crypto = require('node:crypto');

function verifyWebhookSignature(payload, signatureHeader, secret) {
  const computed = crypto.createHmac('sha256', secret).update(payload).digest();
  const provided = Buffer.from(signatureHeader, 'hex');

  return computed.equals(provided);  // DANGEROUS!
}

Why this is vulnerable: Buffer.equals() compares lengths and then compares the bytes, stopping at the first difference - measured on Node 24.3, a difference in the first byte of a one-megabyte buffer was answered in 88 ns against 61,064 ns for one in the last byte. So an attacker who can resend a payload with a guessed signature learns something about where the guess went wrong. On a 32-byte digest that something is small, for the same reason as the API key above, and the whole of it is bounded by how many bytes fit in one comparison step. Treat it as a reason to fix it cheaply rather than as grounds to dismiss it: the comparison strategy is a Node implementation detail, and crypto.timingSafeEqual() is in the same module you already imported.

Secure Patterns

crypto.timingSafeEqual

// SECURE - constant-time comparison regardless of where a mismatch occurs
const crypto = require('node:crypto');

function sha256(value) {
  return crypto.createHash('sha256').update(value, 'utf8').digest();
}

function verifyApiKey(providedKey, storedKey) {
  // A JSON body or a missing header can supply a number, an object, an array
  // or nothing at all, and every Buffer entry point throws TypeError on those.
  if (typeof providedKey !== 'string') {
    return false;
  }

  // Hashing both sides fixes the width at 32 bytes, so timingSafeEqual() can
  // never see a length mismatch and the stored key's length stays hidden.
  return crypto.timingSafeEqual(sha256(providedKey), sha256(storedKey));
}

function verifyWebhookSignature(payload, signatureHeader, secret) {
  if (typeof signatureHeader !== 'string') {
    return false;
  }

  const computed = crypto.createHmac('sha256', secret).update(payload).digest();
  const provided = Buffer.from(signatureHeader, 'hex');

  // The digest is a fixed 32 bytes, so this is a precondition on a public
  // quantity rather than a secret-dependent branch - and it is what catches a
  // malformed header, since Buffer.from(x, 'hex') truncates instead of throwing.
  if (computed.length !== provided.length) {
    return false;
  }
  return crypto.timingSafeEqual(computed, provided);
}

Why this works: timingSafeEqual() compares the two buffers to the end whatever they contain. With no early exit, its running time depends on their length and never on how much of the content matched, so there is nothing for an attacker's timing measurement to key off.

The two functions handle length differently on purpose. A SHA-256 signature is a fixed 32 bytes, so comparing lengths first is a precondition on a public quantity. An API key is not fixed-width, so comparing the raw keys would answer "how long is the stored key" through the same timingSafeEqual() that refuses to compare unequal buffers - which is why it is hashed first, matching the advice in Considerations below rather than contradicting it.

The typeof guards are load-bearing, and this is the one place Node is less forgiving than it looks. Buffer.from(header, 'hex') never throws on a string: it stops at the first character pair that is not hex and returns what it had, so 'zzzz' and '' both produce a zero-length buffer and 'abc' and 'de ad' produce one byte each, all of which then fail the length check. Hand it anything that is not a string and it throws ERR_INVALID_ARG_TYPE instead - undefined from a missing header, null, a number or an object from a JSON body - and an unguarded verifier turns those into a 500. Verified on Node 24.3 across 23 inputs: a genuine signature returns true in either case, and a flipped digit, the four malformed strings, 64 z characters, a genuine signature with two characters appended, undefined, null, a number, an object and an array all return false without throwing.

Framework-Specific Guidance

Express Webhook Middleware

// SECURE - Express middleware verifying an inbound webhook signature
const crypto = require('node:crypto');
const express = require('express');

// Express does not keep the raw request body: express.json() parses it and
// discards the bytes. The signature was computed over those bytes, so capture
// them here or there is nothing to verify against.
const captureRawBody = express.json({
  verify: (req, res, buf) => { req.rawBody = buf; },
});

function verifyStripeStyleWebhook(secret) {
  return (req, res, next) => {
    const signatureHeader = req.get('X-Signature');
    if (!signatureHeader || !Buffer.isBuffer(req.rawBody)) {
      return res.status(401).send('Missing signature');
    }

    const computed = crypto.createHmac('sha256', secret).update(req.rawBody).digest();
    const provided = Buffer.from(signatureHeader, 'hex');

    if (computed.length !== provided.length || !crypto.timingSafeEqual(computed, provided)) {
      return res.status(401).send('Invalid signature');
    }

    next();
  };
}

// Mount the body capture ahead of the check, on the webhook route only
app.post('/hook', captureRawBody, verifyStripeStyleWebhook(secret), handler);

Why this works: Webhook receivers are a common source of hand-rolled signature checks because the verification logic sits in application middleware rather than a framework's built-in auth layer. Routing the comparison through timingSafeEqual() closes the same timing channel a === or Buffer.equals() check would leave open, while still failing closed (401) on any mismatch or missing header.

The verify hook and the Buffer.isBuffer guard are both load-bearing: leaving them out fails in a way that looks like working code. req.rawBody is not an Express property - nothing sets it unless you do - and crypto.createHmac(...).update(undefined) throws TypeError: The "data" argument must be of type string or an instance of Buffer.... Measured on Express 5.2.1, the middleware without the capture answered 500 to every request, a genuine signature included: it rejected all traffic, which passes any test that only checks a forged signature is refused. Verified with the capture in place: a genuine signature returns 200 in upper or lower case hex, and a flipped digit, 'zzzz', 'abc', 'de ad', 64 z characters, a genuine signature with two characters appended, and a missing header all return 401.

Signature verification also has to run on the bytes as received. Re-serializing req.body with JSON.stringify() produces a different string for the same document - key order, whitespace, number formatting - so the HMAC will not match, intermittently and only for some senders.

Passport.js / Session Token Checks

Passport strategies and most session middleware already delegate credential comparison to a hashing library (e.g. bcrypt.compare()) or to crypto.timingSafeEqual() internally. Avoid adding a custom === check on top of a Passport strategy's result or on raw session tokens read from a cookie or header - if a custom check is unavoidable (e.g. comparing a CSRF token from a header against one stored in the session), route it through timingSafeEqual() using the same pattern shown above.

What the strategy delegates is only the comparison, and the comparison is not the expensive part. The usual LocalStrategy body looks up the user and returns done(null, false) when the lookup comes back empty, which skips bcrypt.compare() altogether - and that is where the measurable discrepancy is. Measured on Node 24.3 at cost 12, a wrong password for a real user took 231 ms and an unknown username 0.004 ms: a 61,000x gap, from a strategy that returns the same message either way. Comparing against a constant DUMMY_HASH on the miss closes it, and the dummy has to be a genuine hash - bcrypt.compare(password, '') returns in 0.028 ms and leaves an 8,000x gap. CWE-287 carries the worked strategy.

Considerations

Confirm the compared value is actually a secret. This weakness is about comparisons an attacker can time their way through, which means the value has to be one they are trying to guess: a password hash, an HMAC digest, an API key, a session token, a signature. == on a username, a public identifier or a feature flag is not this finding, however much it looks like the flagged pattern. Record those as false positives with the reason.

Check whether the library already did it. Password verification helpers in the major frameworks compare in constant time internally, so a comparison of the boolean result they return is not a timing leak and does not need changing. The finding is about your own comparison of raw secret bytes.

Decide whether leaking the length matters. Constant-time helpers require both inputs to be the same length, and the natural way to satisfy that is to compare lengths first - which tells an attacker the length. For fixed-size values (a SHA-256 digest, a signature) the length is public anyway and this costs nothing. For variable-length secrets such as API keys, hash both sides first and compare the digests, so the comparison is fixed-width and the length never enters into it.

Testing

  • A correct API key, token and webhook signature still authenticate - the accept case, and the only one a fix that rejects everything fails.
  • A signature header of 'zzzz', 'abc', '', 'de ad' and a genuine signature with two characters appended each return 401, not 500. A 500 means the length precondition is missing and timingSafeEqual() is raising RangeError on attacker-controlled input, which is distinguishable from a 401 without any timing measurement at all.
  • Time three logins - unknown username, known username with a wrong password, known username with the right password - and assert the first two are within noise of each other. A sub-millisecond answer for the unknown username is the enumeration oracle, and no re-scan can see it.
  • Search the codebase for other ===, == and .equals( call sites that compare against a hash, token, key or secret field - the reported line is a sample, not the population.

Common Pitfalls

  • Converting buffers to hex or Base64 strings and comparing those with ===, on the theory that encoding avoids the buffer-comparison issue - encoding does not change the comparison semantics; === on the encoded string is exactly as vulnerable as Buffer.equals() on the raw bytes.
  • Wrapping timingSafeEqual() in a try/catch and treating a thrown RangeError as "not equal" without a length precondition - this works functionally, but mixes error-handling flow with security logic and is easy to get wrong if the catch block is later refactored to log or rethrow. An explicit length check before the call is clearer and safer.
  • Fixing the comparison in one webhook handler but leaving an older === check in a second integration (e.g. a legacy payment provider handler) that was added before the pattern was standardized.

Additional Resources