Security2 min read

Hiding the button is not authorisation

Every few months we review an application where permissions are enforced by not rendering the delete button. Here is the failure mode, and the pattern that actually works.

Written by
Content ManagerContent Manager
Published
August 29, 2026
Last revised
September 7, 2026
Reading time
About 2 minutes

Article

Contents (4)

Most authorisation bugs we find in code review are not subtle. They are the same bug: a permission check that exists only in the interface.

The application renders a delete button for administrators and not for anyone else. The API endpoint behind it checks that you are signed in. It does not check that you are an administrator, because the developer reasoned — quite reasonably, at the time — that only administrators can reach it.

#Why this keeps happening

It happens because the interface check is the one you can see. You open the page as a standard user, the button is gone, and the requirement appears to be met. The test passes. The demo works.

What the demo does not do is open the browser's network tab, copy the request, and replay it without the interface in the way.

#The pattern that works

Authorisation belongs in one place, on the server, close to the data. Every mutating operation asks the same question before it does anything else: is this actor permitted to perform this action on this record?

Note the third part. Role checks alone are not enough in a multi-tenant system. A user might genuinely hold the project.update permission and still have no business updating that project.

typescript
export async function updateProject(projectId: string, input: ProjectInput) {
  const user = await requirePermission('project.update');

  const project = await prisma.project.findUnique({
    where: { id: projectId },
    select: { id: true, clientId: true },
  });
  if (!project) notFound();

  // Role is necessary but not sufficient — the actor must also have a
  // relationship to this specific record.
  if (!user.isStaff && project.clientId !== user.clientId) {
    forbidden();
  }

  const parsed = projectSchema.safeParse(input);
  if (!parsed.success) return validationFailure(parsed.error);

  return prisma.project.update({ where: { id: projectId }, data: parsed.data });
}

Four things happen in that order, every time: authenticate, authorise by role, authorise by relationship, then validate. Skipping any of them produces a specific class of bug, and skipping the third is the one that produces the headline.

#Make the interface follow the server

Once the server is authoritative, the interface check becomes what it should always have been: a usability decision. You hide the button because showing a control that will fail is a bad experience, not because hiding it protects anything.

That reframing matters more than it sounds. A team that believes the hidden button is the control will eventually ship an endpoint without one. A team that treats the interface as cosmetic will not.

#How to find these in an existing system

Grep for your mutating endpoints and list them. For each, write down the answer to the three questions above. In our experience the exercise takes an afternoon on a medium-sized application, and it reliably finds at least one endpoint where the answer to the third question is "we assumed".

Then write a test for each one that asserts the refusal. Tests that assert what a user can do are common. Tests that assert what a user cannot do are the ones that catch this bug when someone refactors in two years.

Tagged

  • authorisation
  • rbac
  • code-review

Share this article

A permanent link and an email link, rather than a row of share buttons. A share widget would mean loading somebody else’s script — and their tracking — on every article you read here.

Continue reading