When timingSafeEqual Isn’t
The Setup
EdgeMail is an email platform I extracted from ArgoBox — 6,500 lines of TypeScript, runs entirely on Cloudflare Workers with D1 and R2. Serverless, zero infrastructure, the whole thing deploys with wrangler deploy.
The auth system supports two modes: API key authentication for programmatic access and JWT tokens for session-based auth. Both use Web Crypto (no Node.js crypto module — Workers don’t have it).
For API key comparison, I wrote a timing-safe equality function. If you’re not familiar: regular string comparison (===) returns false as soon as it finds a mismatched character. An attacker can measure how long the comparison takes and figure out the key one character at a time. Timing-safe comparison always takes the same amount of time regardless of where the mismatch is.
Standard stuff. Every auth library does this.
Mine had a bug that made every comparison return true.
The Bug
Here’s a simplified version of what the function looked like:
function timingSafeEqual(a: string, b: string): boolean {
// Make strings the same length for constant-time comparison
if (a.length !== b.length) {
b = a; // Compare a against itself — still constant time
}
const encoder = new TextEncoder();
const bufA = encoder.encode(a);
const bufB = encoder.encode(b);
let result = 0;
for (let i = 0; i < bufA.length; i++) {
result |= bufA[i] ^ bufB[i];
}
return result === 0 && a.length === b.length;
}
See it?
The b = a on line 4 makes the strings equal length for the constant-time loop — that’s the right idea. The XOR loop will always find zero differences because it’s comparing a against a.
The guard is supposed to be the final check: a.length === b.length. If the original lengths were different, this should return false.
But by the time we reach that check, b is already a. So a.length === b.length is always true. Because b IS a.
Every string compares equal to every other string. "correct-api-key" equals "anything-at-all". The function is timing-safe, alright — it safely returns true every time.
Why It Wasn’t Caught Immediately
The initial tests looked like:
expect(timingSafeEqual("abc", "abc")).toBe(true);
expect(timingSafeEqual("abc", "xyz")).toBe(false);
That second test passes because "abc" and "xyz" are the same length. The XOR loop catches the character differences. The bug only manifests with different-length strings:
expect(timingSafeEqual("abc", "ab")).toBe(false); // FAILS — returns true
expect(timingSafeEqual("abc", "abcdef")).toBe(false); // FAILS — returns true
In testing, I was always comparing strings of the same length. The API keys in my test fixtures were all 32 characters. The function worked perfectly for the cases I tested.
It just also worked for every case I didn’t.
The Fix
One line:
function timingSafeEqual(a: string, b: string): boolean {
const lengthMismatch = a.length !== b.length; // Capture BEFORE reassignment
if (lengthMismatch) {
b = a;
}
const encoder = new TextEncoder();
const bufA = encoder.encode(a);
const bufB = encoder.encode(b);
let result = 0;
for (let i = 0; i < bufA.length; i++) {
result |= bufA[i] ^ bufB[i];
}
return result === 0 && !lengthMismatch;
}
Save the length comparison result before b = a clobbers it. That’s it. One variable declaration.
The Test Suite That Found It
I didn’t find this by code review. I found it while writing the test suite.
143 tests across 5 files. All running against Miniflare — not mocks, actual D1 databases and R2 buckets running locally. The tests exercise the real Cloudflare Workers runtime, not some simulation.
The auth tests specifically:
- API key authentication (valid, invalid, missing)
- JWT creation, validation, expiry, tampering
- Dual-mode fallback (try API key, fall back to JWT)
- Cookie extraction
- Different-length key comparison — this is the one
That last test was almost an afterthought. “What if someone sends a truncated API key?” Turns out: it authenticated. Oops.
The Bigger Picture
The rest of the test suite found other things too:
- POST creation routes return 201, not 200. Most of my test assertions were checking for 200 and passing, because 201 is truthy in most frameworks. Had to add explicit status code checks.
- Labels endpoint returns
{labels, counts}, not a flat array. The client was destructuring wrong. - Email DELETE requires the email to be in trash first. The API returns 400 otherwise. Not a bug — a design choice. But the test caught that the client wasn’t handling it.
- Resend accepts sends from unverified domains. It doesn’t reject at the API level — it just bounces the email later. So your test passes but the email never arrives.
12 of the 143 tests hit the real Resend API. Real sends to [email protected] — a test address that accepts but doesn’t deliver. With 1-second delays between calls because Resend’s free tier is 2 requests per second and you hit that fast during a test run.
The Pattern
Crypto code is subtle. A function can pass hundreds of test cases and still have a fundamental flaw if your test inputs don’t cover the right edge cases.
The timingSafeEqual bug existed because:
- The implementation looked correct on visual review
- The initial tests used same-length strings
- Real API keys are usually the same length (generated, fixed format)
- The failure mode was “accepts everything” — which doesn’t cause visible errors during development
Auth bugs that fail open are the worst kind. A broken comparison that rejects everything gets noticed immediately. One that accepts everything works perfectly until someone actually tries to exploit it.
Write the test for different-length strings. Write it first. Then write the function.
…I’ll remember that for next time. Probably.