DEV Community

Ted
Ted

Posted on • Originally published at tedagentic.com

A Discarded Error Becomes an Answer

A user told me password reset was broken. They clicked the link, entered their address, got the confirmation message, and no code arrived. They tried again. They checked spam. Nothing.

The endpoint was returning HTTP 200 with a friendly message every single time.

The send was never reached

The function writes the code to the database before it emails it. So the first useful question was not "did the email fail" but "is there a row". There wasn't — not for either attempt.

That moves the fault upstream of anything to do with email, which is where I had been looking. Something returned early.

Here is the line that did it:

const { data: existingUser } = await supabase
  .schema("auth")
  .from("users")
  .select("id")
  .eq("email", normalizedEmail)
  .maybeSingle();

const userExists = !!existingUser;
Enter fullscreen mode Exit fullscreen mode

That call can never succeed. It reaches for the auth schema through the REST layer, and the REST layer does not expose auth — only public and one other. The request fails every time it is made.

You cannot tell from the code, because the error was never bound. Only data was destructured. The failure had nowhere to go, so it went nowhere, and existingUser came out null.

Three states, one variable

null here is doing an enormous amount of work it was never asked to do.

There are three possible worlds:

  1. The lookup ran and found a user.
  2. The lookup ran and found nobody.
  3. The lookup could not run.

The code models two of them. World three collapses into world two, because both produce null, and !!null is false either way. An infrastructure failure and a factual answer about a person become the same value.

And once they are the same value, the next line acts on it with total confidence:

if (!userExists) {
  // Don't reveal whether the account exists.
  return json({ success: true, message: "If an account exists, a reset code will be sent" });
}
Enter fullscreen mode Exit fullscreen mode

The privacy feature was the camouflage

This is the part I keep turning over.

That branch is correct. It is there on purpose. If a password reset endpoint answers differently for a registered address than an unregistered one, it becomes an oracle: anyone can feed it addresses and learn who has an account. Refusing to distinguish the two cases is standard practice and it is the right call.

But look at what it does in combination with the swallowed error.

The endpoint's job, in that branch, is to be indistinguishable from success while doing nothing. That is the specification. So when a total backend failure routed into it, the failure inherited a response designed from the ground up to look exactly like the working path — to the user, to the client code, to logs, to monitoring.

A deliberate decision to reveal less became the reason nobody could see the outage. The feature was not bypassed or defeated. It worked perfectly, on the wrong input.

I do not think there is a clever fix for that tension. Anti-enumeration branches have to be quiet. What has to change is what is allowed to reach them.

The same shape, three more times that day

Once I knew what I was looking for, it was not one bug.

The same lookup appeared in the function that completes the reset. It needed the user's id to set the new password, got null, and returned "user not found". So even if I had fixed the sending half, a correct code would still have failed to change anything. Two functions, one root cause, and fixing either alone would have looked like the fix didn't work.

A third copy guarded invitations — "does this person already have an account?" It always answered no, so the guard never fired.

The rate limiter counted rows in a table that does not exist. Same pattern: query fails, error discarded, data is null, null?.length || 0 is 0, zero is below the threshold, no limit applied. A rate limiter that had never once limited anything, reporting healthy by returning a number.

Silence is not evidence

The common thread is not carelessness about errors. It is that in each case, an absence was allowed to mean something.

No row means no user. No count means no traffic. No output means no problems. Each of those readings is reasonable, and each is only valid if you already know the check ran. Nothing in the code established that, so the inference ran on an assumption that was never checked.

That is worth separating from the ordinary advice to handle your errors. The problem is not an unhandled exception crashing something. Nothing crashed. The problem is that a failure was converted into a confident, plausible, wrong claim about the world, and then acted on.

What actually changed

The lookup now goes through a database function that can read the table it needs, and — more to the point — the error is bound and checked:

const { data: userId, error: lookupError } = await supabase
  .rpc("auth_user_id_by_email", { p_email: normalizedEmail });

if (lookupError) {
  console.error("lookup failed:", lookupError);
  return json({ error: "Could not process reset request" }, { status: 500 });
}

if (!userId) {
  return json({ success: true, message: "If an account exists, a reset code will be sent" });
}
Enter fullscreen mode Exit fullscreen mode

Same two branches as before, plus a third that was always necessary and always missing. The rule it encodes is small enough to state in one line:

A lookup that could not run is not a verdict about the user.

It is a 500. It is our problem, not a fact about them. The quiet branch is still quiet, but only genuine answers are allowed to reach it.

The rate limiter got the same treatment — a failed count now returns an error rather than a permissive zero. If you cannot tell whether someone is over the limit, "0" is a guess wearing a number's clothing.

It used to work

The part I find hardest to sit with is that this was not a thing nobody ever got right. It worked for months. Here is what the broken lookup replaced:

// Check if user exists
const { data: userData, error: userError } = await supabase.auth.admin.listUsers();
const userExists = userData?.users?.some(user => user.email === email);
Enter fullscreen mode Exit fullscreen mode

That version goes through the admin API, which can read the table. It loads every user and scans them in memory, which is genuinely wasteful and gets worse as you grow. Replacing it was a reasonable thing to want.

The commit that replaced it left a comment saying exactly what it was for:

// Targeted user lookup (O(1)) instead of listing all users
Enter fullscreen mode Exit fullscreen mode

That comment is honest and the intent behind it is good. The new query really is O(1). It is also a query against a schema the REST layer does not expose, so it returns nothing, forever — and because the error was dropped, "nothing" got read as "no such user" and routed into the branch designed to look like success.

An optimisation replaced a slow correct answer with a fast wrong one, and every downstream signal said fine.

That reframes what the failure actually is. Not neglect, not inexperience — a deliberate improvement to a working system, applied without a way to notice it had stopped working. The old code was scanning every user on every request and that was visible: it would have shown up in timings, in cost, in anything watching. The replacement was instant, because doing nothing is very fast.

Nobody reported it in the ten weeks it was broken. They tried, got a reassuring message, and left. I had no signal at all, because the endpoint was reporting success the entire time.

It was found because one person mentioned it in passing, and because a row that should have existed didn't.

If you have an endpoint that deliberately returns the same response whether or not it did anything, go and check that it does the thing. It is the one place in your system where working and broken were designed to look identical, and that design is doing its job for both.

Top comments (0)