September 26, 2026
Authentication vs Authorization: How I Hacked a Web App Without Stealing a Password
A code review that turned into a security review

By Armando Peรฑa Tamayo
8 min read
A code review that turned into a security review
A colleague of mine built a web app for a restaurant and asked me to take a look at the whole thing. He'd written it, I've been developing longer, and he wanted a second pair of eyes.
Security was part of what I looked at. I've been building web software for more than eight years โ twelve if you count university โ and security was present the whole time, but mostly as an abstract checklist: check the permission, wire up the middleware, move on. I'd never looked at it from the other side, which is why I didn't take it as seriously as I should have.
This time I wanted to see what happens when someone doesn't play by the rules. And I did it, among other things, as practice.
I started the way I always do: the structure, how it was organized, what looked off.
Users, roles, and an admin panel
The first thing I noticed was that this wasn't a simple landing page. It had users, sessions, and a hierarchy.
The flow was roughly this: a user signs in, the app knows who they are, that user has a role, and the role decides what they can see and do. And above all of it there was an admin panel, built into the same site.
None of that struck me as strange. It's a normal design, and it's the design I've shipped myself.
What struck me as strange was something else, and it came from before I'd even accepted the job. I have written down, in my own notes, the concern that comes up for me when I'm learning pentesting: a lot of the architectures I've built myself are functional but not properly tested, and it bothers me that certain routes end up hidden only by logic that lives in the client, and are therefore reachable by anyone.
That was a worry about my own code. Suddenly I had a real application in front of me to check it against.
Authentication looked fine
Before I got into roles I wanted to confirm the base was sound: if the login were broken, nothing else would mean anything.
Users could authenticate. The system knew who each one was. The session persisted across requests, and the app responded differently depending on who was signed in. All of that worked.
So far it looked well built.
That was the most important misreading of the whole review: I conflated "people can authenticate" with "this is fine."
Then I started looking at authorization
I'd been studying that difference in PortSwigger's labs, so it was fresh. And it isn't my idea โ it's the first thing you learn when you start looking at access control seriously.
But the difference wasn't new to me. Authentication, authorization, roles, RBAC: I'd implemented it many times, always with several middlewares in the backend, one to authenticate and one for the role. That is exactly what you're supposed to do. What was new was the direction I was looking from. For years I asked the builder's question, "does my code check the role?" and never the attacker's, "is there a route where nobody asks?"
I was after something specific: whether the roles were actually being enforced in the backend, or only in the interface. I wasn't testing whether I could bypass the login โ I already knew I couldn't.
That's the exact point where the review stopped being about code.
A button that wasn't there
The first clue was almost too obvious, and it still took me a moment to process.
The app had pages that didn't appear in the interface for certain roles. The button didn't render. The link wasn't in the menu. Entirely correct from a user-experience standpoint: a normal user doesn't see things that aren't theirs to see.
The problem is that decision was made on the client. "Don't show" and "don't allow" were the same thing, and that decision happened in the browser.
I have the exact anti-pattern written in my notes, and I'd put it there as a warning to myself:
<!-- โ WRONG: the protection exists ONLY in the client -->
<script>
var isAdmin = false
if (isAdmin) {
// render admin panel
}
</script><!-- โ WRONG: the protection exists ONLY in the client -->
<script>
var isAdmin = false
if (isAdmin) {
// render admin panel
}
</script>The button doesn't appear. Fine. But making the button disappear doesn't change the route it points to, or what the server returns when someone requests that route directly.
Hiding a button isn't authorization. It's UX.
The route was still there, written in the JavaScript
The link didn't render, but the route existed. This is where the browser's developer tools come in: even if the button doesn't exist on screen, the condition that decides whether to show it lives in the JavaScript the interface ships to every visitor. I opened the inspector, searched for that logic, and the route was right there, written next to it.
There was no guessing, no brute force, no blind attempts: I read the code the application itself hands to anyone who visits. I wrote down the route and requested it. And the server answered, with no alarm, because to it the request was perfectly ordinary: it came from a valid session, from a user who had signed in correctly.
This is the nuance that took me a while to understand. The application never decided I wasn't allowed. There was no check that failed. There was simply no check.
Testing the endpoints with Burp
The next step was finding out whether that was an isolated case or the rule, and this is where Burp came in.
I pointed the browser at the proxy and browsed with my normal user, who was not an administrator, without touching anything: just watching which requests the interface made on its own while I used it. Burp captured all of them. Then I replayed those requests one at a time, and several returned data or actions that belonged to a role with more privileges.
And at that point there was no doubt left: the administrative endpoints were not checking the role before responding. They trusted that the interface had already filtered what I was supposed to see, and they didn't re-check it themselves.
That's the classic broken access control pattern, and it doesn't require an app to break in some spectacular way. It's enough for one check to live only in the interface. Hiding the button and protecting the server are two different things, and only the first one had been done.
And it's worth saying something uncomfortable about tools like this: the heavy lifting isn't done by the tool. Burp doesn't decide anything, it just hands you the request that was already leaving your session and lets you send it again. What exposes the failure is replaying it with a role that shouldn't be allowed and watching the server answer anyway.
I reached administrator functionality
And here's the payoff, which is the reason this post exists.
I was authenticated. The system knew who I was at every moment. There was no authentication bypass, no brute force, no session theft, nothing remotely like it.
What I did was use my legitimate session, with my legitimate role, and request directly what the interface wouldn't have let me request.
I reached administrator functionality without ever seeing, obtaining, or stealing the administrator's password.
And I want to be precise about the scope, because exaggerating would be dishonest. What I got was access to functionality that didn't belong to my role. I did not get control of the server, or the database, or anyone's credentials. It was an authorization problem, not a takeover.
Authentication vs authorization
With the story told, the difference is pretty clear.
Authentication is: who are you? It's a question answered once, at login, and it produces an identity. This application answered it well. You could authenticate, the session was valid, and the server always knew whether someone was there or not.
Authorization is: what are you allowed to do? It's a different and more specific question, and it isn't answered once. It's answered per resource and per action. Is this order this user's? Can this role touch this screen? Does this endpoint accept my role, or anyone?
And that's the underlying difference: the application got the first question right, and skipped the second in several places.
When I reached the admin functionality I hadn't broken authentication. Authentication had done its job the whole time. What was missing was the authorization check that should have accompanied each of those requests. The backend never asked what that user was allowed to do; it assumed that since the interface had already filtered, it could answer.
What the developer should have checked
This is what I'd tell my colleague, and what I apply in my own projects. Every point comes from something I saw here, not from a manual.
- Don't assume hiding a UI element prevents access. Frontend visibility is user experience; permission is a different thing.
- Check the permission on the server, always and everywhere. If the answer depends on the role, the answer has to decide it with the role. Every sensitive endpoint has to check it itself, without taking on faith that something else already did.
- Don't assume a rare or "secret" route is protected. Not being in the menu doesn't mean nobody can request it.
I think about it in layers, and only three of them interest me:
Layer What it protects What it has to do Interface Only what is seen Show or hide elements Route or proxy Block before rendering Validate the session Server Verify session and permissions Check the permission on every endpoint
The third one decides. The first two are user experience, which is exactly why they can't be the protection.
What was missing in me
I'm still not a security professional, and I want to be clear. But it's worth saying something the story can leave ambiguous: this wasn't a beginner's mistake.
In the projects where it mattered I used RBAC without exception, with separate middlewares for authentication and for role. That is exactly what you're supposed to do. The problem was never knowledge: it was that I carried security as a checklist, present but abstract, without the rigor it deserves.
And that has very concrete consequences. It's entirely possible that in some project, under a delivery deadline, I left a route without its role middleware. Or that I solved something by hiding the button in the interface and trusting the calendar. I can't be sure, because I haven't gone back through those projects with these eyes, but it is exactly the pattern I found in my colleague's app.
The jump this case gave me is what matters. I'd read about authorization and understood it abstractly: "check permissions on the server," one more rule on a list. Watching it fail in an application I was actually reviewing turned it into something else โ a button that didn't appear and a route that answered anyway. The difference between the two stopped being a nuance and became the entire finding.
And there's a second, less comfortable lesson: the app did authenticate correctly, and that's exactly what makes this kind of bug easy to miss. If the login had been broken, I'd have found it in two minutes. The problem lived in the question nobody was asking.
Final thoughts
I didn't need the administrator's password. The app had already authenticated me, with my own account and my own role, and it still never checked what I was authorized to do. When that fails, no amount of a working login fixes it: authentication gets you in, authorization decides what you can do once you're inside.
That's the lesson I've taken most, and I think it reaches past security: the serious vulnerabilities aren't always the interesting ones. A lot of the time they're just a question the application never asks. What should this user be allowed to do here?
And the check I now apply to everything I build: request directly what the interface doesn't show you, and look at what the server returns. You don't need exotic tools: the browser's devtools show you the route, and a proxy like Burp lets you replay the request with whichever role you want. If the response is the same one a user without privileges would get, there's something to review.
Adapted from my original post on TallerWeb.
GitHub: https://github.com/Armando284