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
| Aspect | Details |
|---|---|
| CWE Reference | CWE-208 (Timing Attack) |
| Severity | High (security vulnerability) |
| Auto-Fix | 💡 Suggests crypto.timingSafeEqual() |
| Category | Security |
| ESLint MCP | ✅ Optimized for ESLint MCP integration |
| Best For | Node.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
| Risk | Impact | Solution |
|---|---|---|
| ⏱️ Timing Leak | Comparison time reveals match length | Use crypto.timingSafeEqual() |
| 🔑 Secret Brute Force | Attack one character at a time | Constant-time comparison |
| 🔒 Token Bypass | Forge tokens by timing analysis | Never use === for secrets |
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
secretPatterns | string[] | ['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 comparisonHow 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
| Vulnerability | CWE | OWASP | CVSS | Impact |
|---|---|---|---|---|
| Timing Discrepancy | 208 | A02:2021 | 5.9 Medium | Secret value leak |
| Observable Timing | 208 | A02:2021 | 5.3 Medium | Brute 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))) // ✅ AfterPhase 3: Enforcement
{
rules: {
'node-security/no-timing-unsafe-compare': 'error'
}
}Related Rules
no-hardcoded-credentials- Detect hardcoded secretsno-weak-hash-algorithm- Detect weak hash algorithms
Known False Negatives
Non-Standard Variable Names
Why: Only configured patterns are detected.
// ❌ NOT DETECTED - unusual variable name
if (userValue === dbValue) { ... } // Actually comparing tokensMitigation: 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
- Node.js timingSafeEqual - Official documentation
- CWE-208: Observable Timing Discrepancy - Official CWE entry
- Timing Attacks Explained - Classic article on timing attacks
⚙️ Options
| Option | Type | Default | Description |
|---|---|---|---|
secretPatterns | string[] | ["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 |
untrustedSources | string[] | ["req","request","ctx","event"] | Identifier roots treated as attacker-controlled (default: req, request, ctx, event) |
reportUnverifiedComparisons | boolean | false | Report on a secret-looking name alone, without an attacker-controlled operand. Restores the pre-inversion behaviour. |
reportLiteralComparands | boolean | false | Report 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'). |
nonSecretWords | string[] | ["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. |
nonSecretTails | string[] | ["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 onBoth 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{ "jsPlugins": ["oxlint-plugin-node-security"], "rules": { "node-security/no-timing-unsafe-compare": "error" }}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.