TLDR:
===short-circuits on the first mismatched byte — an attacker can probe your secret byte-by-byte using response timing.timingSafeEqualfrom Node'scryptomodule fixes it in one function. There's a length-mismatch gotcha you need to handle too.
The Setup
I have a few Next.js apps — a KOL CRM (key opinion leader relationship manager) for an ecommerce business, a supply-chain sync dashboard — each with cron routes that hit Shopify, Recharge, and Supabase on a schedule.
Those routes are protected by a CRON_SECRET environment variable.
The auth check was a one-liner in every route file. Something like:
if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
Looks fine. Works fine. Ships to production. Carries on for months.
The Problem
During a full security audit on June 5th, SEC-6 flagged it: === is not constant-time.
JavaScript string equality short-circuits the moment it finds a mismatch. "ABCD" versus "ABCE" fails faster than "ABCD" versus "ZZZZ" — by one comparison cycle.
That sounds microscopic. And honestly, over a real network with jitter? Hard to exploit cleanly.
But here's the thing — it's a timing oracle. An attacker can send thousands of requests with slightly different secrets, measure the response times statistically, and learn how many leading bytes they got right. Byte by byte, your 32-character secret becomes guessable.
Maybe nobody could pull that off against my Vercel deploy in practice. But the fix is one function call. Why leave the door cracked?
What I Found (And It Was Worse Than I Thought)
The audit found the === pattern in five separate route files across the supply-chain app.
And four of them had a dev hatch: they skipped the check entirely when NODE_ENV !== 'production'.
That's not a hatch. That's a wide-open door on every dev branch, every preview deploy, every CI environment that doesn't explicitly set NODE_ENV=production.
The Fix
Node's crypto module has timingSafeEqual. It compares two buffers in constant time — always the same number of operations, regardless of where the bytes differ.
There's a gotcha though. timingSafeEqual throws if the two buffers have different lengths. So the naive fix still leaks information: returning early on a length mismatch tells an attacker whether they got the length right.
The correct version burns a dummy comparison on length mismatch before returning false:
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a)
const bb = Buffer.from(b)
if (ab.length !== bb.length) {
timingSafeEqual(bb, bb) // burn the cycle — don't return early
return false
}
return timingSafeEqual(ab, bb)
}
Then I extracted it into src/lib/cron-auth.ts — a shared helper both apps import — and removed the dev hatch entirely. Every environment now requires a real CRON_SECRET; for local dev you just set any value in .env.local and pass it as the Bearer token.
Why This Matters to Me
I'm not paranoid about most theoretical attacks. But there's a category of fix that's low-effort, high-cleanliness, and closes something real. This is that category.
The bigger lesson: copy-pasting auth logic into five route files was always going to drift. The timing fix forced the refactor. One helper, one test file, one place to get it right.
That's the win I actually care about.