Skip to main content
Israr Ahmed Labs

Security & Access Control

Hiding a button is not access control

If a permission is only checked in the interface, anyone who can send a request can bypass it. Where the check has to live, and how to test that it does.

Israr Ahmed · · 2 min read

A common first version of a role system goes like this: administrators see an "Approve" button, everyone else does not. It looks correct in every demo, and it protects nothing. The button is a suggestion; the request behind it is what matters.

The rule

Every page, action and download re-checks permission on the server. The interface may hide controls for convenience, but the server never assumes a request came from that interface.

In a server-rendered application this usually means one small set of guard functions that every protected entry point calls first:

export async function approveExpense(formData: FormData) {
  const user = await requirePermission("expenses.approve");
  // ... then check the record, not only the module
}

A check in a layout is not enough: layouts do not re-run on every navigation, and server actions are separate endpoints.

Module access is not record access

Holding "view enquiries" should not automatically mean "see every enquiry". Decide the scope explicitly. Two examples:

  • A support person sees only the enquiries assigned to them.
  • A person who raised an expense cannot approve it, whatever role they hold.

Report a record you may not see as not found, not forbidden. Otherwise the difference tells an attacker which identifiers exist.

Do not let people raise their own access

Roles bring their own risks. If someone can edit users, can they give themselves the top role? If they can reset passwords, can they reset the password of someone more powerful and sign in as them? Rules that close these holes:

  • You can only give a role whose permissions you already hold.
  • You can only act on an account whose access does not exceed yours.
  • The last administrator can neither be disabled nor demoted.

Make it testable

Keep the decision logic in plain functions with no database access, and unit-test them. Then add a request-level test: sign in as a restricted user, call the endpoint directly, and confirm you get a refusal. Test direct URLs, exports and bulk actions too: those are the places where the user interface is not in the way.

Leave a trail

For sensitive operations — sign-ins, role changes, publishing, exports — write an append-only audit entry that records who did what and when, without storing the sensitive content itself. When something goes wrong, the answer to "who changed this?" should be a search, not an argument.

  • security
  • RBAC
  • permissions
  • authorization

Updated . Need help with something like this? Get in touch.

Have a project or a problem to solve?

Tell us what you are trying to achieve. We will reply by email with questions or a suggested next step.