Skip to main content
interlace
Plugin: node-securityRules

no-timing-unsafe-compare

Disallow timing-unsafe comparison of secrets

Detects timing-unsafe comparison of secrets using === or == operators. This rule is part of eslint-plugin-node-security and provides LLM-optimized error messages with fix suggestions.

🚨 Security rule | 💡 Provides suggestions | ⚠️ Set to error in recommended

Quick Summary

AspectDetails
CWE ReferenceCWE-208 (Timing Attack)
SeverityHigh (security vulnerability)
Auto-Fix💡 Suggests crypto.timingSafeEqual()
CategorySecurity
ESLint MCP✅ Optimized for ESLint MCP integration
Best ForNode.js applications comparing tokens, secrets, or signatures

Vulnerability and Risk

Vulnerability: Using === to compare secrets enables timing attacks. The comparison short-circuits on the first mismatched character, so the time taken reveals information about how many characters matched.

Risk: An attacker can measure comparison times to guess secret values character-by-character. For example, comparing API keys or HMAC signatures with === allows attackers to brute-force the correct value.

Rule Details

This rule detects ===, ==, !==, and != comparisons of a secret against a value an attacker controls. Both halves are required by default:

  • one operand reads off a request (req, request, ctx, event — see untrustedSources), and
  • the other has a name suggesting it is a secret (token, secret, password, hash, signature, … — see secretPatterns).

Two secret-named values with no request operand (submittedToken === validToken) are not reported: nothing shows an attacker can time the comparison. reportUnverifiedComparisons: true reports on the secret-looking name alone.

The same comparison through a helper counts too: buf.equals(other), Buffer.compare(a, b), _.isEqual(a, b) and its siblings — whose first two arguments are the values even when a customizer or options bag follows (_.isEqualWith(a, b, fn), deepEqual(a, b, { strict: true })) — and a local wrapper that compares two of its parameters. A crypto derivation (createHmac(…).digest(), a .sign() or #hmac() method) is a secret by shape whatever it is named, and so is a value read from req.session/req.user/await, however many aliases away. A value compared with itself or a reading of itself (token !== token.trim(), token !== token) is one value, and is not reported.

Why This Matters

RiskImpactSolution
⏱️ Timing LeakComparison time reveals match lengthUse crypto.timingSafeEqual()
🔑 Secret Brute ForceAttack one character at a timeConstant-time comparison
🔒 Token BypassForge tokens by timing analysisNever use === for secrets

Configuration

The options and their defaults are listed under ⚙️ Options below.

{
  rules: {
    'node-security/no-timing-unsafe-compare': ['error', {
      // also report a secret-named comparison with no request operand
      reportUnverifiedComparisons: true,
    }]
  }
}

Examples

❌ Incorrect

// A request header compared to a stored API key - HIGH risk
function check(req) {
  return req.headers['x-api-key'] === process.env.API_SECRET; // ❌ Leaks timing information
}

// A request token compared with ===
if (req.body.token === validToken) {
  // ❌ Vulnerable
  grantAccess();
}

// HMAC verification with ===
const expectedHash = crypto
  .createHmac('sha256', secret)
  .update(data)
  .digest('hex');
if (req.headers['x-signature'] === expectedHash) {
  // ❌ Timing attack possible
  processData();
}

// With reportUnverifiedComparisons: true, the names alone are enough
if (submittedToken === validToken) {
  // ❌ only under reportUnverifiedComparisons
}

✅ Correct

import crypto from 'crypto';

// Constant-time comparison
function validateApiKey(userKey: string, storedKey: string) {
  const userBuffer = Buffer.from(userKey);
  const storedBuffer = Buffer.from(storedKey);

  // Lengths must match for timingSafeEqual
  if (userBuffer.length !== storedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(userBuffer, storedBuffer); // ✅ Safe
}

// HMAC verification (constant-time)
function verifyHmac(data: Buffer, receivedHmac: Buffer, secret: Buffer) {
  const expectedHmac = crypto
    .createHmac('sha256', secret)
    .update(data)
    .digest();

  if (receivedHmac.length !== expectedHmac.length) {
    return false;
  }

  return crypto.timingSafeEqual(receivedHmac, expectedHmac); // ✅ Safe
}

// JWT verification (use library)
import jwt from 'jsonwebtoken';
jwt.verify(token, secret); // ✅ Library handles timing-safe comparison

How Timing Attacks Work

Secret:   "s3cr3t_k3y"
Guess 1:  "aaaaaaaaaa" → Fast rejection (0 chars match)
Guess 2:  "saaaaaaaaa" → Slightly slower (1 char matches)
Guess 3:  "s3aaaaaaaa" → Even slower (2 chars match)
...
Guess N:  "s3cr3t_k3y" → Slowest (all chars match)

Each additional matching character adds measurable time, allowing attackers to discover secrets character-by-character.

Security Impact

VulnerabilityCWEOWASPCVSSImpact
Timing Discrepancy208A02:20215.9 MediumSecret value leak
Observable Timing208A02:20215.3 MediumBrute force enablement

Migration Guide

Phase 1: Discovery

{
  rules: {
    'node-security/no-timing-unsafe-compare': 'warn'
  }
}

Phase 2: Replacement

// Replace === with timingSafeEqual
if (req.body.token === validToken)  // ❌ Before
if (crypto.timingSafeEqual(Buffer.from(req.body.token), Buffer.from(validToken)))  // ✅ After

Phase 3: Enforcement

{
  rules: {
    'node-security/no-timing-unsafe-compare': 'error'
  }
}

Known False Negatives

Non-Standard Variable Names

Why: Only configured patterns are detected.

// ❌ NOT DETECTED - unusual variable name
if (userValue === dbValue) { ... }  // Actually comparing tokens

Mitigation: Add patterns to configuration or use consistent naming.

Indirect Comparisons

Why: Cross-function data flow not tracked.

// ❌ NOT DETECTED
function compare(a, b) {
  return a === b;
}
compare(userToken, validToken);

Mitigation: Search codebase for comparison patterns.

Further Reading

⚙️ Options

OptionTypeDefaultDescription
secretPatternsstring[]["token","secret","password","hash","signature","mac","hmac","digest","apiKey","api_key","api-key","auth","credential","bearer","jwt","csrf","nonce","ssn","social_security","social-security","pii","private_key","private-key","privateKey","access_token","access-token","accessToken","refresh_token","refresh-token","refreshToken","session_id","session-id","sessionId","auth_token","auth-token","authToken","encryption_key","encryption-key","encryptionKey"]Variable name patterns that indicate secrets
untrustedSourcesstring[]["req","request","ctx","event"]Identifier roots treated as attacker-controlled (default: req, request, ctx, event)
reportUnverifiedComparisonsbooleanfalseReport on a secret-looking name alone, without an attacker-controlled operand. Restores the pre-inversion behaviour.
reportLiteralComparandsbooleanfalseReport a secret-named value compared against a string literal (password === 'mypass'), in either order, on the name alone. Off by default because string comparands are usually tags (revokedToken === 'access').
nonSecretWordsstring[]["author","authors","authored","authoring","authorship","hashtag","hashtags"]Whole words that mean a secretPatterns match was a collision (default: author, authors, authored, authoring, authorship, hashtag, hashtags). Replaces the list.
nonSecretTailsstring[]["count","counts","limit","limits","usage","total","size","length","price","cost","quota","address","addresses","index","rank","percent","path","paths","pathname","pathnames","endpoint","endpoints","route","routes","hostname","host","port","origin","kind","kinds","type","types","flag","flags","category"]Trailing words that make the value a measurement or location rather than a credential (default: count, counts, limit, limits, usage, total, size, length, price, cost, quota, address, addresses, index, rank, percent, path, paths, pathname, pathnames, endpoint, endpoints, route, routes, hostname, host, port, origin, kind, kinds, type, types, flag, flags, category). Replaces the list.

Literal comparands

By default a comparison against a string literal is skipped: in revokedToken === 'access' the literal is a tag, not a secret, and a credential written into the source is a CWE-798 finding for secure-coding/no-hardcoded-credentials, not a timing oracle. reportLiteralComparands: true reports a secret-named value against a string literal on the name alone, in either order. It is what eslint-plugin-security's detect-possible-timing-attacks does, so a drop-in replacement for that plugin turns it on.

{
  "node-security/no-timing-unsafe-compare": [
    "error",
    { "reportLiteralComparands": true },
  ],
}
if (password === 'mypass') {
} // reported with the option on
if ('mypass' === password) {
} // reported with the option on

Both halves of the vocabulary are yours

secretPatterns says this is a secret; nonSecretWords and nonSecretTails say …except when. All three are substring-or-word matches on identifiers, so all three are tunable — a rule that lets you widen the positive list but not the exclusions makes the widening unusable.

{
  "node-security/no-timing-unsafe-compare": [
    "error",
    {
      // Re-admit a bare `key`, then exclude the senses this codebase uses.
      "secretPatterns": ["token", "secret", "key", "signature"],
      "nonSecretWords": ["author", "hashtag", "keyboard", "keyring"],
      // `tokenChapter` is a position in a document, not a credential.
      "nonSecretTails": [
        "count",
        "limit",
        "size",
        "length",
        "index",
        "chapter",
      ],
    },
  ],
}

nonSecretWords matches any word of the identifier; nonSecretTails matches only the last one. Setting either to [] removes that guard, which restores the collisions it exists to prevent (authorId matching auth, tokenCount matching token).

Use this rule

npm install -D oxlint-plugin-node-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-node-security"],  "rules": {    "node-security/no-timing-unsafe-compare": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-node-security'],    rules: {      'node-security/no-timing-unsafe-compare': 'error',    },  },});

oxlint-plugin-node-security re-exports eslint-plugin-node-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.