Why Comparing API Keys With === Is a Security Bug (and How to Fix It in Node.js)
How timing attacks leak secrets through string comparison, and how to use crypto.timingSafeEqual for API keys, tokens and webhook signatures.
Admin
7 min read·
A while ago I was reviewing a small internal API I had written: a handful of Next.js route handlers protected by a shared API key in a header. The check was one line, token !== process.env.ADMIN_API_KEY, and it had worked fine for months. Then a security-minded friend pointed at that line and asked, "Is that comparison constant time?" It was not, and that question sent me down a rabbit hole about timing attacks that I think every full-stack developer should take once.
In this post I will explain what a timing attack is, why a plain string comparison can leak information, and how to fix it in Node.js with crypto.timingSafeEqual, including the gotchas that tripped me up: unequal lengths, webhook signatures and edge runtimes.
The innocent-looking bug
Here is roughly what my original code looked like:
// app/api/admin/route.ts
export async function POST(req: Request) {
const token = req.headers.get("x-api-key");
if (token !== process.env.ADMIN_API_KEY) {
return new Response("Unauthorized", { status: 401 });
}
// ...do privileged work
return Response.json({ ok: true });
}
Nothing about this looks wrong. The problem is how !== compares strings. JavaScript engines are free to stop at the first character that differs, and in practice comparison routines short-circuit: if the first byte is wrong, the comparison returns almost immediately; if the first ten bytes are right, it takes a tiny bit longer before it bails out.
That difference is measured in nanoseconds, but it is not zero. An attacker who can send many requests and measure response times precisely can, in theory, guess a secret one character at a time: try every possible first character, keep the one that is consistently slowest, then move on to the second character. Instead of brute-forcing an enormous keyspace, they solve a series of small problems.
Is this actually exploitable?
I want to be honest here, because security posts often oversell risks. Over the public internet, network jitter is huge compared to the time it takes to compare a few bytes, so a remote timing attack against a single string comparison is hard. Attackers compensate with lots of samples and statistics, and the noise is much smaller when they can get close to your server, for example from the same cloud region or the same internal network.
My take: you do not need to panic about every === in your codebase, but for comparisons involving secrets (API keys, webhook signatures, HMACs, password reset tokens, session tokens) using a constant-time comparison is cheap, standard practice and removes a whole class of questions from your next security review. It is also exactly what tools like GitHub and Stripe tell you to do when verifying their webhook signatures.
The fix: crypto.timingSafeEqual
Node.js ships a function built for this: crypto.timingSafeEqual(a, b). It compares two buffers (or typed arrays / DataViews) and always looks at every byte, so the time it takes does not depend on where the first difference is.
There is one sharp edge: both inputs must have the same byte length. If they do not, timingSafeEqual throws a RangeError instead of returning false. My first attempt was to check the length first and return early, which works, but it leaks the length of the secret through timing as well. For a random API key that is usually not a big deal, but there is a neater trick: hash both values first.
// lib/safe-compare.ts
import { createHash, timingSafeEqual } from "node:crypto";
function sha256(value: string): Buffer {
return createHash("sha256").update(value, "utf8").digest();
}
/**
* Compares two secrets in constant time.
* Hashing first gives both buffers the same length (32 bytes),
* so timingSafeEqual never throws and length is not leaked.
*/
export function safeCompare(received: string, expected: string): boolean {
return timingSafeEqual(sha256(received), sha256(expected));
}
SHA-256 always produces 32 bytes, so the buffers are guaranteed to match in length, nothing throws, and an attacker learns nothing about the secret's length. Hashing is fast enough that the extra cost is irrelevant for a request handler.
And the route handler becomes:
// app/api/admin/route.ts
import { safeCompare } from "@/lib/safe-compare";
export const runtime = "nodejs";
export async function POST(req: Request) {
const token = req.headers.get("x-api-key") ?? "";
const expected = process.env.ADMIN_API_KEY;
if (!expected) {
// Fail closed if the secret is missing in this environment
return new Response("Server misconfigured", { status: 500 });
}
if (!safeCompare(token, expected)) {
return new Response("Unauthorized", { status: 401 });
}
return Response.json({ ok: true });
}
Two small details I like here. First, I explicitly opt into the Node.js runtime, because node:crypto is what provides timingSafeEqual. Second, the handler fails closed: if the environment variable is missing, it refuses every request rather than accidentally comparing against undefined.
Verifying webhook signatures the right way
The place I now see this pattern most is webhooks. GitHub, for example, signs each delivery with HMAC-SHA256 using your webhook secret and sends the result in the X-Hub-Signature-256 header, formatted as sha256= followed by the hex digest. Your job is to recompute the HMAC over the raw request body and compare the two in constant time.
// app/api/webhooks/github/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
export async function POST(req: Request) {
const secret = process.env.GITHUB_WEBHOOK_SECRET!;
const signature = req.headers.get("x-hub-signature-256") ?? "";
// Sign the exact raw body; re-serialized JSON will not match
const rawBody = await req.text();
const digest =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(signature, "utf8");
const b = Buffer.from(digest, "utf8");
// Length check is safe here: the expected length is public (71 chars)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody);
// ...handle event
return new Response(null, { status: 204 });
}
Notice that here I do check the length before calling timingSafeEqual. That is fine because the expected length is not secret: everybody knows a hex SHA-256 digest with the sha256= prefix is 71 characters. The length check just prevents the RangeError when someone sends garbage.
The bigger lesson from webhooks is to sign the raw body. I once spent an hour debugging failed signatures because I was calling req.json() and then JSON.stringify-ing the result back. Whitespace and key order can change, the bytes no longer match, and the HMAC fails. Read the body as text once, verify it, and only then parse it.
What about edge runtimes?
If you run middleware or route handlers on an edge runtime, you typically only have the Web Crypto API, which does not expose a timingSafeEqual equivalent. You have two good options.
The first, and my preference for HMACs, is to let Web Crypto do the comparison for you: import the secret as an HMAC key with crypto.subtle.importKey and call crypto.subtle.verify("HMAC", key, signature, data), which checks the signature for you instead of handing you bytes to compare yourself.
The second is a small XOR-based comparison that touches every byte regardless of where differences occur:
// Constant-time comparison for runtimes without node:crypto
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
This is the classic approach used in many libraries. It is not guaranteed to be perfectly constant time, because a JIT compiler is allowed to optimize code in ways you cannot fully control, but it avoids the obvious early exit and is far better than ===. As before, if the length itself is secret, hash both inputs first (for example with crypto.subtle.digest("SHA-256", ...)) so they always have equal length.
Going one step further: do not store keys in plaintext
Once I started thinking about how I compare secrets, I also had to think about how I store them. For user-facing API keys, the same approach used for passwords applies: store a hash, not the key. Because API keys are long random strings rather than human-chosen passwords, a fast hash like SHA-256 is generally considered fine here; slow password hashes like bcrypt or Argon2 are meant for low-entropy secrets.
// Storing and checking API keys without keeping them in plaintext
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export function generateApiKey() {
const key = "sk_" + randomBytes(32).toString("base64url");
const hash = createHash("sha256").update(key).digest("hex");
return { key, hash }; // show `key` once, store only `hash`
}
export function verifyApiKey(received: string, storedHash: string) {
const receivedHash = createHash("sha256").update(received).digest();
return timingSafeEqual(receivedHash, Buffer.from(storedHash, "hex"));
}
Now a database leak does not hand out working credentials, and verification is still a constant-time comparison of two 32-byte buffers. A practical tip: keep a short, non-secret prefix of the key (like the first 8 characters) in a separate indexed column so you can look up the right row quickly and show users which key is which in your dashboard.
A quick checklist I now use in code review
- Is a secret being compared with ===, !== or ==? Replace it with a constant-time helper.
- Can the comparison throw because of unequal lengths? Hash first, or check length only when the length is public.
- Does the handler fail closed when the secret is missing from the environment?
- For webhooks, is the signature computed over the raw body, before parsing?
- Are long-lived API keys stored as hashes rather than plaintext?
Security is rarely about one dramatic fix. It is mostly about removing small, quiet assumptions like "comparing two strings is harmless."
Key takeaways
- Normal string comparison can exit early, which can leak information about a secret through response timing.
- Use crypto.timingSafeEqual in Node.js for API keys, tokens and signatures; remember it throws if the buffers differ in length.
- Hashing both values with SHA-256 before comparing guarantees equal lengths and hides the secret's length.
- For webhooks, compute the HMAC over the raw request body and compare it in constant time.
- On edge runtimes, prefer crypto.subtle.verify for HMACs or a XOR-based comparison, and store API keys hashed, not in plaintext.
Written by Admin
Published October 2, 2026 · Updated Oct 3, 2026