crypto.timingSafeEqual()
Compares two equal-length buffers in constant time so the result leaks no timing information.
crypto.timingSafeEqual(a, b)- Added in Node.js v6.6.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$a | ArrayBuffer | Buffer | TypedArray | DataView | The first value to compare. Must have the same byte length as b. |
$b | ArrayBuffer | Buffer | TypedArray | DataView | The second value to compare. Must have the same byte length as a. |
Return value
booleantrue when the two inputs contain identical bytes, false otherwise, with the comparison taking the same time either way.
Errors
Throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH if a and b have different byte lengths, and ERR_INVALID_ARG_TYPE if either is a plain string rather than a byte source.
Changelog
- PHP v15.0.0The a and b arguments can also be an ArrayBuffer.
Comparing secrets with === leaks information. JavaScript's string comparison returns as soon as it finds a differing byte, so a guess sharing the first five bytes of a valid token takes measurably longer to reject than one differing at byte zero. Over enough requests an attacker reconstructs the secret one byte at a time. crypto.timingSafeEqual() always examines every byte, so the elapsed time reveals nothing about how close a guess was.
Compare digests, not raw secrets
The function throws when the two buffers differ in length — and that throw is itself a length oracle. The clean fix is to compare fixed-length digests: HMAC both values under a random per-process key, or hash them, and compare the results. Every digest of a given algorithm is the same size, so the length check can never fail and the length of the supplied secret is never revealed.
What trips people up
Passing strings. This function accepts only Buffer, TypedArray, DataView or ArrayBuffer; a bare string throws ERR_INVALID_ARG_TYPE. Convert with Buffer.from() — and note that when the input is user-controlled, its length is attacker-controlled too, which is exactly why the digest approach is safer than checking lengths first.
Finally, the guarantee covers only this call. If your surrounding code does a database lookup on a match but not on a miss, you have reintroduced a timing signal the comparison was meant to remove.
Example
When to use it
- Checking a webhook signature header against the one you computed
- Validating a submitted API key against the stored value
- Confirming a password-reset token matches without leaking a prefix
- Comparing a derived scrypt key at login without a timing side channel
Frequently asked questions
Why does crypto.timingSafeEqual throw "Input buffers must have the same byte length"?
Because it refuses to compare buffers of different lengths, since a length-dependent result would itself be a timing signal. Hash or HMAC both values first and compare the fixed-length digests, which makes the mismatch impossible and hides the real secret's length.
Can I pass strings to crypto.timingSafeEqual?
No, it only accepts Buffer, TypedArray, DataView or ArrayBuffer, and a string throws ERR_INVALID_ARG_TYPE. Convert with Buffer.from(value), or better, compare digests of both values so the lengths always match.
Do I really need constant-time comparison for API keys?
Yes wherever an attacker can submit guesses and measure response times, which includes any public HTTP endpoint. Early-exit comparison leaks how many leading bytes were correct, reducing a 64-byte secret from an impossible search to a few thousand requests.
More examples
A safe comparison for any two secrets
Hashing both sides first turns any pair of inputs into two 32-byte values, so the length error becomes structurally impossible and no information about the real secret's length escapes. This is the pattern to reach for whenever the value being compared arrives from a request.
const crypto = require('node:crypto');
// A random per-process key means the digests reveal nothing even if logged.
const BLIND = crypto.randomBytes(32);
const blind = (value) => crypto.createHmac('sha256', BLIND).update(value).digest();
// Works for inputs of ANY length, because both sides become 32 bytes.
function secretEquals(a, b) {
return crypto.timingSafeEqual(blind(a), blind(b));
}
console.log('identical :', secretEquals('sk_live_abc', 'sk_live_abc'));
console.log('different values :', secretEquals('sk_live_abc', 'sk_live_xyz'));
console.log('different lengths :', secretEquals('short', 'a-much-longer-secret'));
// The naive version cannot handle the last case at all.
try {
crypto.timingSafeEqual(Buffer.from('short'), Buffer.from('a-much-longer-secret'));
} catch (err) {
console.log('raw comparison ->', err.code);
}API key middleware
Hashing both keys to a fixed 32 bytes means a short or empty Authorization header is rejected normally instead of throwing a 500, and the rejection path takes the same time whether the key was one byte or a near-miss. The response body is identical for every failure, so nothing distinguishes a malformed key from an almost-correct one.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const API_KEY = Buffer.from(process.env.API_KEY || '');
function authenticate(req, res, next) {
const header = req.get('authorization') || '';
const presented = Buffer.from(header.replace(/^Bearer /, ''));
// Compare digests so an attacker learns nothing from the length,
// and so a wrong-length key does not throw.
const a = crypto.hash('sha256', API_KEY, 'buffer');
const b = crypto.hash('sha256', presented, 'buffer');
if (!crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'unauthorized' });
}
next();
}
app.get('/admin/stats', authenticate, (req, res) => res.json({ users: 1234 }));
console.log('protected route registered');Why === is not enough
The near-miss guess takes measurably longer because the loop runs 63 more iterations before bailing out. An attacker who can time your endpoint exploits exactly this gradient, trying each byte value and keeping whichever is slowest — 64 bytes fall in a few thousand requests rather than 2^512 guesses.
const crypto = require('node:crypto');
const SECRET = 'a'.repeat(64);
// Early-exit comparison: cost depends on the shared prefix length.
function naiveEquals(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
const measure = (guess) => {
const start = process.hrtime.bigint();
for (let i = 0; i < 200_000; i++) naiveEquals(SECRET, guess);
return Number(process.hrtime.bigint() - start) / 1e6;
};
const noPrefix = measure('b' + 'a'.repeat(63)); // differs at byte 0
const longPrefix = measure('a'.repeat(63) + 'b'); // differs at byte 63
console.log('differs at byte 0 :', noPrefix.toFixed(1), 'ms');
console.log('differs at byte 63:', longPrefix.toFixed(1), 'ms');
console.log('later mismatch is slower:', longPrefix > noPrefix);
// Constant time regardless of where the difference falls.
const tse = (g) => crypto.timingSafeEqual(Buffer.from(SECRET), Buffer.from(g));
console.log('timingSafeEqual near-miss:', tse('a'.repeat(63) + 'b'));
Discussion