I started learning web app hacking yesterday, focused entirely on one OWASP Top 10 category: Broken Access Control. By the end of the day I'd solved three PortSwigger Web Security Academy labs, and every single one of them boiled down to the exact same root mistake, just wearing a different costume. Here's what I found, how I found it, and why it matters.
The One Rule Behind Everything
Before the labs, here's the concept that ties this whole post together:
The server should never trust the client to tell it who the client is.
Every vulnerability below is a variation of a backend trusting something the browser sent it — a URL, a request body field, a cookie — instead of independently verifying identity and permissions on the server side.
Lab 1: Unprotected Admin Functionality
The setup: A shop app with no visible admin link anywhere in the UI.
What I tried first: Guessing /admin directly. That returned a 404 — real apps are rarely that obvious.
What actually worked: Checking robots.txt. This file exists to tell search engine crawlers which pages not to index — but it's just plain text, readable by anyone. Sure enough:
User-agent: *
Disallow: /administrator-panel
The irony: to hide a page from Google, the developer had to write its exact path in a public file. Navigating to /administrator-panel dropped me straight into a fully functional, completely unauthenticated admin panel. I deleted the target user and solved the lab in a couple of minutes.
Root cause: The admin panel had zero access control — no login required at all. "Hidden" via convention, not actually protected.
Lab 2: Unprotected Admin Functionality (Unpredictable URL)
Same idea, harder version — this time robots.txt was empty and the URL was a random string, not a guessable word. Wordlist brute-forcing with ffuf (a command-line fuzzing tool) came back empty too, since no dictionary would contain a randomly generated path.
The actual answer was hiding in plain sight in the page's HTML source, inside inline JavaScript:
var isAdmin = false;
if (isAdmin) {
var adminPanelTag = document.createElement('a');
adminPanelTag.setAttribute('href', '/admin-3yrflr');
adminPanelTag.innerText = 'Admin panel';
topLinksTag.append(adminPanelTag);
}
The key lesson here: if (isAdmin) only controls whether the link gets displayed. The actual path string, /admin-3yrflr, still gets shipped to every single visitor's browser inside the JavaScript file — admin or not. Since I could see the string, I could just navigate there directly, bypassing the intended "check" entirely.
Root cause: Sensitive paths shouldn't be baked into client-side code at all, conditional or not — the browser already has the string the moment the page loads.
Takeaway for future recon: any time you see JS with setAttribute('href', ...), fetch(...), or similar path assignments — even wrapped inside an if — the string inside is worth testing directly, regardless of whether the condition would ever be true for you.
Lab 3: User Role Controlled by Request Parameter
This one lived in a cookie instead of a URL. Checking DevTools → Application → Cookies showed a cookie literally named Admin, set to false.
I changed it to true in DevTools, refreshed, and navigated to /admin — full admin access, no real admin credentials ever involved.
Here's roughly what the vulnerable backend logic looks like:
// LOGIN
res.cookie('Admin', user.role === 'admin' ? 'true' : 'false');
// ADMIN CHECK
if (req.cookies.Admin === 'true') {
return res.render('admin-panel');
}
The server computes the role correctly at login — but then hands the result of that decision straight to the browser as an editable cookie. Since cookies are just text stored client-side, anyone can open DevTools and rewrite them freely.
The fix, and the general pattern for all three labs, looks like this instead:
// LOGIN — only an opaque session ID goes to the client
const sessionId = generateSessionId();
sessionStore.save(sessionId, { userId: user.id, role: user.role }); // stored server-side only
res.cookie('session', sessionId, { httpOnly: true });
// ADMIN CHECK — role is looked up server-side, never trusted from the client
const session = sessionStore.get(req.cookies.session);
if (session.role !== 'admin') {
return res.status(403).send('Forbidden');
}
The browser only ever holds a meaningless random session ID. The actual role lives entirely server-side. Even if you tamper with the cookie, there's nothing meaningful to tamper with.
What I'd Tell Someone Starting This Today
Enumeration comes before exploitation. Every one of these bugs required actually looking — at
robots.txt, at page source, at cookies — before there was anything to exploit.JavaScript doesn't need to be "understood" to be useful for security work. I don't write JS fluently, but I don't need to — I just need to recognize shapes:
if (role === ...),fetch(...), cookie references, hidden path strings. That pattern-matching skill came fast.It's always the same bug wearing different clothes. URL in a JS comment, role in a POST body, role in a cookie — structurally identical mistake every time: the server trusted something the client controls.
Three labs down, a full OWASP Top 10 left to go. Next up: digging into Injection and IDOR properly.
This was written as part of my personal learning log while working through the PortSwigger Web Security Academy — all testing was done exclusively against PortSwigger's own intentionally vulnerable lab environments, not live third-party sites.
About the author
- just a simple guy
Comments
Join the discussionNo comments yet. Be the first to comment.




