commit&push

~/blog $ git show 2026-08-13

Roles Are Not Permissions: Why Hiding a Page Is Not a Guard

· 6 min read · Victor Benavides

Early in my career I "secured" an admin feature by hiding the button.

It felt like a fix. I could see it work: log in as a normal user, the menu item is gone. Log in as an admin, it's back. Demoed it. Everyone nodded. Two weeks later someone pasted the URL into a chat, a colleague clicked it out of curiosity, and the page loaded beautifully for a person who had no business seeing it. Nothing had been protected. I had changed what the application offered, not what it allowed.

That's the whole lesson, and I've watched versions of it repeat in every codebase I've worked in since — including my own, when I started designing authorization for a multi-tenant platform and discovered the problem has more layers than "admin or not."

Three different questions

Most authorization bugs come from collapsing three questions into one:

  • Who are you? — authentication. A token proves identity.
  • What may you do? — authorization. A rule decides an action.
  • What should you see? — presentation. A UI decides a layout.

The third is the only one with instant feedback. You hide the menu item and you can watch it disappear. There is no equivalent satisfying moment for adding a server-side check: nothing visibly happens, the feature works exactly as before, and no one thanks you. So the visible one gets done and the invisible one gets a ticket.

But the browser is not a security boundary. It's a rendering suggestion, running on a machine you don't control, in a program the user can inspect, with a network tab that shows every endpoint your app has ever called. If the only thing standing between a user and an action is a component that didn't render, you have decoration, not authorization.

"Admin" is not a fact about a person

Here's where it got genuinely interesting for me, and where I think the standard advice stops too early.

Single-tenant apps teach you to think of roles as properties of users: this person is an admin. Multi-tenant systems break that immediately, because "admin" is never a global truth. You're an admin of something. Of one venue, one workspace, one organization — and quite possibly a nobody in the next one over.

The moment the same account can appear in two contexts, a role stops being an adjective and becomes a relationship. Which means the interesting object in your system isn't the user and isn't the role — it's the membership: this identity, in this context, with these permissions.

That distinction sounds academic until you look at where roles usually live: in the token. Tokens are flat. They're a list of strings, handed out at login, and they have no idea which venue the user is currently looking at. Ask a flat claim "is this person an admin?" and it will happily answer yes — without ever being told admin of what. The check passes. The wrong data is served. Nothing errors, nothing logs, and every dashboard stays green.

The rule I ended up with: never authorize from a role string alone. Authorize from the tuple — identity, context, permission — resolved on the server, for the specific thing being touched.

Tokens are a snapshot, not a subscription

The second trap is time.

A token is a photograph of your permissions at the instant you logged in. Revoke someone's access, demote them, remove them from an organization — none of that reaches into a token already sitting in someone's browser. Until it expires or refreshes, that user carries authority you already took away. If your sessions are long-lived and never refresh, "removed" can mean "still fully privileged until they happen to log out."

Which produces a genuinely uncomfortable question worth asking about your own system: if I revoke someone's access right now, when does it actually take effect? If the honest answer is "whenever their token expires," then your revocation is a suggestion with a delay attached.

The only test that counts

There's a simple test that cuts through all of this, and it takes about a minute.

Take a valid token from a user who should not be able to do something. Call the endpoint directly — curl, Postman, whatever. Skip the UI entirely, because attackers and curious colleagues will.

If you get a 403, you have authorization. If you get data, you have a hidden button. And if your reaction to reading that paragraph was "I'm not actually sure what would happen" — that uncertainty is itself the finding. Authorization you haven't tested from outside the UI is authorization you're assuming.

The trade-off, because there's always one

Doing this properly is not free, and I'd rather say so than pretend.

Every endpoint pays for a check — in latency, in code, in one more thing to get wrong. A permission model rich enough to express real organizations gets complicated fast, and complicated security models fail in their own special ways: nobody understands them, so everyone grants broadly "to make it work." You will break your own internal tools during rollout, repeatedly, and each break will feel like the enforcement is the problem rather than the missing grant.

There's a real decision hiding in there, too: enforce immediately and discover every gap the hard way, or run in audit mode first and log what would have been denied. Audit mode is kinder and it's also a trap — a log nobody reads is the security equivalent of a green dashboard on a system that isn't working. Pick a date to flip it, or you never will.

What you get in exchange is a system where the answer to "can this person do that?" lives in one place, is testable without a browser, and doesn't change based on what someone typed into the address bar.

The uncomfortable version

If I'm honest, the hidden-button instinct never fully goes away. It's the fastest way to make a demo look right, and every one of us has shipped it at least once while telling ourselves the real check would come later.

So the question I've learned to ask isn't "did we hide it?" It's: what happens if they find the URL anyway? If the answer is nothing bad, you're fine. If the answer is a shrug, you don't have a permission — you have a layout.

Saturday: I put a working terminal on this blog, and typing sudo in it produces an authorization decision I'm rather more confident about. ✨