DEV Community

Cover image for A Broken Button Is Obvious. A Broken Auth Check Isn't.
Chukwuemeka Andre Ozomma
Chukwuemeka Andre Ozomma

Posted on

A Broken Button Is Obvious. A Broken Auth Check Isn't.

Here's a bug that will pass code review, pass a quick manual test, and sit in production for months before anyone notices — because it only breaks on the one input nobody thinks to try.

Say you're building a delete endpoint. A user should only be able to delete their own resource — a recipe, a document, a post, doesn't matter. The obvious approach:

js
export async function DELETE(request, { params }) {
  const { id } = await params;
  const recipe = await prisma.recipe.findUnique({ where: { id: Number(id) } });

  const session = await auth();

  if (recipe.userId !== session?.user?.id) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  await prisma.recipe.delete({ where: { id: Number(id) } });
  return Response.json({ success: true });
}`

Enter fullscreen mode Exit fullscreen mode

Test it with a real recipe ID you own — works. Test it with someone else's recipe ID — correctly forbidden. Ship it.

Now request a recipe ID that doesn't exist.

recipe comes back null. The very next line — recipe.userId — throws, because you can't read a property off null. In a lot of setups, that unhandled exception doesn't cleanly return a 403 or a 404. Depending on your error handling upstream, it can surface as a raw 500, sometimes with a stack trace, sometimes with enough detail to tell an attacker things about your schema they shouldn't be able to see. And if your error handling is too forgiving in the wrong direction, malformed input can slip past the check that was supposed to stop it — the check never even ran, because the code crashed before reaching it.

Either way: the ownership check silently didn't happen. Not because the logic was wrong. Because of what order things ran in.

Why this is worse than it looks

The dangerous part isn't that this crashes. Crashes get noticed. The dangerous part is how easy it is to write a slightly different version of this same mistake that doesn't crash — it just quietly returns the wrong answer instead. The moment your authorization check depends on data that might not exist yet, you've made "does this check even run" conditional on something you didn't intend to make it conditional on.

A broken button is obvious the second you click it. A broken authorization check can be technically "working" for every test case you happened to think of, and wrong for the one you didn't.

The fix is about order, not logic

js
export async function DELETE(request, { params }) {
  const { id } = await params;
  const session = await auth();

  if (!session?.user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const recipe = await prisma.recipe.findUnique({ where: { id: Number(id) } });

  if (!recipe) {
    return Response.json({ error: "Not found" }, { status: 404 });
  }

  if (recipe.userId !== session.user.id) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  await prisma.recipe.delete({ where: { id: Number(id) } });
  return Response.json({ success: true });
}

Enter fullscreen mode Exit fullscreen mode

Same logic. Same three questions being asked. The only thing that changed is the order: check who's asking, then check whether the thing they're asking about even exists, then check whether they're allowed to touch it. Every guard clause runs before anything that could be null gets touched.

The general rule I keep coming back to: authorization code should be readable top to bottom as a sequence of gates, each one safe to evaluate no matter what came before it. If a later check depends on data a null case could have skipped past, you don't have three independent checks — you have one check with a hole in it.

Where this actually bites

This exact shape of bug shows up constantly, not just in delete routes:

  • An "is this user an admin" check that runs after a database call that assumed the user already exists
  • A permission check on a field that's undefined for legacy records created before that field existed
  • A check that correctly blocks the UI button from rendering, while the actual API route behind it has no equivalent server-side check at all — the client-side gate was doing all the work, and it's trivial to bypass by just calling the endpoint directly

That last one is worth sitting with for a second: hiding a button is not authorization. If the check only exists in the frontend, the "check" is a UI suggestion, not a security boundary.

The habit worth building

When you're reviewing any authorization code — your own, a teammate's, or something AI just generated for you — trace it in this order:

  1. - Is identity checked first, before anything else runs?
  2. - Does the code correctly handle the resource not existing, before it tries to check ownership of that resource?
  3. - Is the check enforced on the server, not just hidden in the UI?

If you can't answer all three by reading the function top to bottom, it's worth another look — regardless of how confidently it was written, by you or by an AI tool.

I write about backend patterns like this one in more depth in AI-Assisted Backend Development — happy to dig into any specific edge case in the comments if it's useful.

Top comments (0)