This is one of those vulnerabilities that sounds theoretical right up until you see it actually demonstrated, and then it's hard to un-see in every piece of code comparing a secret with a plain ===.
The Setup That Looks Completely Reasonable
// app/api/webhooks/custom/route.ts
export async function POST(request: Request) {
const providedSecret = request.headers.get('x-webhook-secret');
if (providedSecret !== process.env.WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
// process the webhook
}
This is checking a shared secret in a header, a common pattern for a custom webhook you control both sides of, an internal API key check, a CRON_SECRET verification, anything comparing a client-supplied value against a known correct one. The logic reads correctly. It's also, technically, exploitable, though the practical difficulty of actually pulling it off varies a lot depending on context.
Why === Leaks Timing Information
JavaScript's string equality comparison, like most languages' default string comparison, checks characters left to right and returns false the instant it finds a mismatch, without checking the remaining characters at all. This means comparing "a1234" against a correct secret "z9999" takes a tiny bit less time than comparing "a1234" against a correct secret starting with "a" but differing later, since the second comparison gets one character further before exiting.
That timing difference is measured in microseconds, genuinely tiny, but measurable, repeatable, and statistically extractable given enough attempts. An attacker who can send many requests and precisely measure response time can, in principle, determine a secret one character at a time, trying each possible next character and observing which one takes microscopically longer to reject, since a correct prefix means the comparison ran slightly further before failing.
Why This Is Genuinely Harder to Exploit Than It Sounds, But Not Impossible
This isn't a trivially easy, five-minute attack in most real deployments. Network jitter, server load variance, and the sheer number of requests needed to extract a meaningful timing signal all make this a genuinely difficult attack to pull off reliably over the open internet, and it's a much bigger concern for something like an internal service on a low-latency, controlled network than for a public API with normal internet-level timing noise. That said, "harder to exploit" isn't "not exploitable," timing attacks against real systems have been demonstrated successfully in research and in the wild, and dismissing the entire vulnerability class as purely theoretical is exactly the assumption that makes it worth actually fixing rather than shrugging off.
The Actual Fix: Constant-Time Comparison
Node.js's built-in crypto module provides exactly the tool for this, timingSafeEqual, which compares two buffers in a way that always takes the same amount of time regardless of where or whether a mismatch occurs.
// lib/secureCompare.ts
import crypto from 'crypto';
export function secureCompare(a: string, b: string): boolean {
const bufferA = Buffer.from(a);
const bufferB = Buffer.from(b);
// Buffers of different lengths would throw, so check length separately first,
// this length check itself doesn't leak the actual secret's content
if (bufferA.length !== bufferB.length) {
return false;
}
return crypto.timingSafeEqual(bufferA, bufferB);
}
// app/api/webhooks/custom/route.ts
import { secureCompare } from '@/lib/secureCompare';
export async function POST(request: Request) {
const providedSecret = request.headers.get('x-webhook-secret') ?? '';
if (!secureCompare(providedSecret, process.env.WEBHOOK_SECRET as string)) {
return new Response('Unauthorized', { status: 401 });
}
// process the webhook
}
timingSafeEqual always compares every byte, regardless of where a mismatch occurs, so the time taken no longer leaks any information about how much of the guess was actually correct.
Where This Applies in a Real Next.js Project
Anywhere a secret, token, or API key gets compared against a known correct value using plain equality. A custom webhook secret check, a CRON_SECRET header check, an internal API key validation, a manually-implemented session token comparison. It's worth noting Stripe's own SDK, stripe.webhooks.constructEvent, already handles this correctly internally, using proper signature verification rather than naive string comparison, so that specific, common case is already safe. The risk is specifically in custom comparisons you write yourself for secrets you control both sides of.
What This Doesn't Apply To
Comparing non-secret values, checking whether a status field equals "active", comparing a username for a lookup rather than an authentication decision, doesn't need constant-time comparison, since there's no secret whose value an attacker could meaningfully extract through timing. This matters specifically for genuine secrets and tokens, not general string equality throughout an app.
The Actual Checklist
Any custom secret or token comparison you write yourself should use crypto.timingSafeEqual, not ===.
Check the length first, separately, before calling timingSafeEqual, since it throws on mismatched buffer lengths rather than safely returning false, and a naive length check beforehand doesn't leak meaningful information the way comparing the actual secret content would.
Established libraries handling this internally, like Stripe's webhook verification, don't need this applied on top, check whether the library you're using already does it correctly before assuming you need to add your own layer.
If you have any custom secret comparison in your codebase using plain ===, worth checking today whether it's genuinely low-risk (an internal tool, unlikely target) or worth the small effort of switching to a constant-time comparison. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)