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 where at least one operand has a name suggesting it's a secret (token, password, key, secret, hash, signature, etc.).

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

OptionTypeDefaultDescription
secretPatternsstring[]['token', 'secret', 'key', ...]Variable name patterns for secrets
{
  rules: {
    'node-security/no-timing-unsafe-compare': ['error', {
      secretPatterns: [
        'token', 'secret', 'key', 'password', 'hash', 'signature',
        'mac', 'hmac', 'digest', 'apiKey', 'api_key', 'auth',
        'credential', 'bearer', 'jwt', 'csrf', 'nonce'
      ]
    }]
  }
}

Examples

❌ Incorrect

// Timing-unsafe comparison of API key - HIGH risk
function validateApiKey(userKey: string, storedKey: string) {
  return userKey === storedKey; // ❌ Leaks timing information
}

// Comparing tokens with ===
if (submittedToken === validToken) {
  // ❌ Vulnerable
  grantAccess();
}

// HMAC verification with ===
const expectedHash = crypto.createHmac('sha256', secret).update(data).digest();
if (receivedHash === expectedHash) {
  // ❌ Timing attack possible
  processData();
}

✅ 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 (userToken === validToken)  // ❌ Before
if (crypto.timingSafeEqual(Buffer.from(userToken), 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.