October 23, 2026
The frontend knew who was an admin. The server forgot to check
JavaScript often contains a near-complete map of an application’s roles and routes. The bug appears when the backend mistakes a hidden…

By Devansh Patel
4 min read
JavaScript often contains a near-complete map of an application's roles and routes. The bug appears when the backend mistakes a hidden button for a permission check.
A login page can make an application look empty. No registration, no dashboard and no obvious features to test.
The JavaScript may tell a different story.
In March 2026, a researcher writing as ophacks described finding this route inside a client-side file during an authorised vulnerability-disclosure programme:
/Account/UpdatePwd?username=/Account/UpdatePwd?username=The normal site redirected unauthenticated users to a login page. The password-update route did not. It also returned different responses for valid and invalid usernames, which allowed account enumeration.
The researcher could not bypass the old-password check, so this was not a demonstrated account takeover. Bugcrowd accepted it as a P3. That nuance makes the case more useful, not less. The JavaScript exposed a sensitive workflow, while the server missed authentication on part of it. A second control prevented the worst outcome.
You can read the original account in From JavaScript Recon to Account Takeover: Finding a Hidden Endpoint.
The browser already has a permission map
Modern frontends need to know what to display. They often contain route definitions, feature flags, role checks and API clients that look like this:
if (user.role === "admin") {
show("/admin/users");
}if (user.role === "admin") {
show("/admin/users");
}That check may be perfectly reasonable for the interface. Normal users should not see an admin button they cannot use.
The mistake is assuming that hiding the button also protects /admin/users.
Anyone can call an HTTP endpoint without using the interface. If the backend does not repeat the permission check, the client-side condition becomes the only thing standing between a normal account and an administrative action.
OWASP's frontend threat guidance describes this exact failure: route guards or feature flags control the interface, but the backend accepts the request without independently checking the user's role.
Two public cases, one pattern
The ophacks report started with an endpoint name. Another researcher, swehtpantz, found hardcoded credentials in a JavaScript file on an administrative subdomain. The credentials did not work on the visible login form, but the surrounding code showed where they belonged. When used against a different endpoint, they produced an authenticated response. The programme paid $200 for the finding.
The researcher's write-up is a useful reminder that a string only makes sense in context. A username, token or route may look dead until the code around it shows the expected header, request format or backend service.
Both cases began with passive reading. The security impact came from a server-side mistake:
- a sensitive workflow accepted unauthenticated requests;
- a backend accepted credentials shipped to every browser.
The frontend revealed the path. It did not create the vulnerability.
Build an authorisation matrix before sending requests
When I review a JavaScript-heavy application, I write down what the frontend appears to believe about access.
Function Route Expected role Object involved Server response to low privilege View own profile /api/users/{id} User User ID To be tested Export report /api/reports/{id}/export Analyst Report ID To be tested Invite member /api/orgs/{id}/members Owner Organisation ID To be tested Disable account /api/admin/users/{id} Admin User ID To be tested
The first four columns come from reading routes, components and API wrappers. The final column comes from controlled testing with accounts you own and roles the programme allows you to use.
This makes access-control testing less random. Instead of spraying endpoints, you compare the permission model advertised by the client with the permission model enforced by the server.
Useful clues include:
- conditions involving
role,permission,scope,isAdminorcanManage; - disabled menu items that still carry an API route;
- old components that reference deprecated endpoints;
- GraphQL operations hidden behind feature flags;
- object identifiers passed directly from the browser;
- different API versions used by web and mobile clients.
Test the server, not the button
The safest test is also the cleanest. Use two accounts you control, give them different roles, and replay the same authorised request across that boundary.
Change one thing at a time:
- keep the endpoint and object fixed, but switch the session;
- keep the session fixed, but change an object ID to another owned test object;
- compare read and write operations;
- check whether alternate methods or API versions apply the same policy.
Stop when you have enough evidence. Do not modify real users, enumerate production accounts at scale or trigger destructive admin actions. A minimal proof can use a harmless read, a reversible change on your own object, or a request that the programme has approved.
PortSwigger's access-control guide gives the core rule: hidden URLs and user-controlled role parameters are not protection. The application must deny access at the server for every protected resource and action.
What a good report proves
"I found an admin endpoint in JavaScript" is reconnaissance.
A useful report answers four questions:
- Which role should be allowed?
- Which role was actually allowed?
- What data or action crossed that boundary?
- Which server-side check was missing or inconsistent?
Include the normal request, the low-privilege replay and the smallest evidence of impact. If another control blocks the final step, say so. The ophacks case is a good model here: the endpoint and user enumeration were exposed, but the old password was still required.
Accuracy builds trust with the triager. Calling every reachable route a full account takeover does the opposite.
The fix belongs on the backend
The frontend can continue hiding irrelevant controls. That is good interface design. It just cannot make the final decision.
The backend should derive identity and permissions from a verified session or token, apply a deny-by-default policy, and check both the requested function and the requested object. The same rule must cover every API version and alternate client.
Client-side code will always describe part of the application. For a bug hunter, that code is a map. For a developer, it should never be a lock.
Stay curious. Stay dangerous. 💻🕶