Web Security

Access Control Testing: Horizontal and Vertical Privilege Escalation

Why access control bugs are so easy to miss

Access control bugs do not usually announce themselves. A SQL injection returns an error, an XSS shows a popup, but a missing authorization check often returns a perfectly normal response. That normal response is the vulnerability. I have missed these bugs before because I was looking for something that looked broken. The page loaded, the status code was 200, and the data looked like it belonged to me. It did not occur to me to ask whether the data was actually mine.

This post is my personal study note on testing horizontal and vertical privilege escalation. I am writing it because access control is one of the areas where a simple checklist can make a huge difference. The checklist is boring, but it works. It forces me to test the same endpoint with different accounts, different identifiers, and different HTTP methods, even when the UI makes it look like I only have one option.

What I mean by horizontal and vertical escalation

Horizontal privilege escalation happens when a user can access another user's resources. The classic example is an order that belongs to user B, but user A can read it by changing an ID in the URL. The two users have the same role, so the problem is not about role boundaries. It is about object-level ownership.

Vertical privilege escalation happens when a user can perform actions that belong to a higher role. A normal user might be able to call an administrator endpoint, create a user, or change a setting that should only be changed by an admin. The problem is a missing role check. Both types of bugs can exist in the same application, and they often share the same root cause: the server does not verify who is making the request.

The reason I separate them in my notes is that the testing process is slightly different. For horizontal escalation, I need at least two accounts with the same role. For vertical escalation, I need two accounts with different roles. In both cases, I need to compare the responses carefully and look at the data that is actually returned.

The first step is mapping the application

I cannot test access control if I do not know where the resources are. The first step is mapping the application and building a list of resource types. An order, an invoice, a message, a profile, a document, a payment method, and a project are all resource types. Each resource type has one or more endpoints that create, read, update, delete, or list the resource.

I use the authenticated session to walk through the application and record every API call. I write down the method, the path, the parameters, and the response. This map becomes my test matrix. Without it, I am testing randomly and will miss most of the endpoints. The map also helps me understand which identifiers are used by the application. Some identifiers are numbers, some are UUIDs, and some are embedded in signed values. Each type needs a different testing approach.

The identifiers can appear in many places. They can be in the URL path, the query string, the request body, a custom header, or a JSON field. I search the request data for values that look like object references. If the same value appears in multiple endpoints, that endpoint becomes a high-priority target because the identifier is clearly used to select a resource.

Setting up the accounts

For horizontal testing, I create two accounts that have the same role and are not related to each other. I use different names, different email addresses, and ideally different device fingerprints. The two accounts need to have data that can be compared. I create an order, a message, a file, or whatever resource is relevant, so that each account owns something distinct.

For vertical testing, I need an account with a lower role and an account with a higher role. If I do not have access to an admin account, I look for endpoints that appear to be admin-only and test them with the lower account. I also look at the JavaScript bundle for route names that are hidden from the UI. The UI hides buttons, but the backend may not hide the functionality.

I keep a table of accounts in my notes. The table includes the account name, the role, the resources owned, and the token or cookie I am using for that account. This table is important because I often switch between accounts during the test, and it is easy to confuse them. If I send a request with the wrong token, the result tells me nothing.

Horizontal testing: changing the identifier

The core horizontal test is simple. I take a request from account A, change the resource identifier to a value owned by account B, and send the request using account A's session. If the response contains B's data, the endpoint is vulnerable. I repeat this test for every resource type and every HTTP method.

The read endpoint is the most obvious place to start, but it is not the only place. An update endpoint might allow me to change B's order status. A delete endpoint might let me remove B's file. A list endpoint might include B's records even when the UI only shows A's records. Each method needs its own test.

The status code is not enough. A response of 200 with a body that contains B's data is a clear finding. A response of 403 might mean the endpoint is protected, or it might mean the application returned a generic error after partially processing the request. I compare the full response body, the headers, and any side effects. If the response is different between two accounts, I investigate why.

Some applications use unpredictable identifiers, such as UUIDs. The fact that an ID is hard to guess does not make the endpoint secure. I still test with the second account's identifier because the bug is about the missing ownership check, not about whether the ID can be predicted. If I can read B's data by using B's UUID, the finding is valid.

Vertical testing: crossing role boundaries

Vertical testing starts with identifying endpoints that should be restricted. I look for paths such as /admin, /api/v1/users, /internal, and /manage. I also look at the network requests made when an admin account is used. The frontend may load a separate set of API calls for admin functions. Those API calls are the ones I want to test with the lower-role account.

The test is simple: send the admin request with the lower-role account and observe the response. If the lower account can create a user, modify a setting, or read internal data, the application has a vertical escalation issue. I do not stop at the first endpoint. I test every admin endpoint I can find, including hidden ones from the JavaScript bundle.

Frontend controls are not security controls. A button that is hidden from the UI does not protect an endpoint. I can replay the exact request that the admin account sends, even if I cannot see the button in my own interface. The only question is whether the backend checks the role.

Testing process that I actually use

My process has five steps. First, I map the application and list every resource and endpoint. Second, I create the accounts I need and collect baseline responses. Third, I test horizontal escalation by changing identifiers across every method. Fourth, I test vertical escalation by replaying admin requests with a lower account. Fifth, I review the results and look for partial leaks, such as responses that reveal part of another user's data.

I automate the repeated identifier swaps, but I do not automate the analysis. A script can send a hundred requests, but I still need to compare the response bodies manually. I use the script to generate candidates and then review the interesting ones by hand. This combination saves time without losing the context that the human reviewer needs.

When I find an issue, I record the full request and response. I include the account used, the identifier, the method, the path, and the data returned. I also record the baseline response from the owner account, so that the developer can see the difference. A finding without this context is hard to reproduce and easy to dismiss.

Common implementation flaws I look for

Some access control bugs are caused by the same implementation patterns. One pattern is checking ownership only in the list endpoint but not in the detail endpoint. Another pattern is using the user-supplied object type instead of the resource stored in the database. A third pattern is checking the role at the frontend only. A fourth pattern is caching responses by URL without including the account in the cache key, which can leak one user's data to another.

I also look for mass assignment. If the request body includes a userId field and the server trusts it, I can create a resource that belongs to another user. Indirect object references are another common pattern. The application might use a simple mapping between an internal ID and a public reference. If I can change the public reference, the server may not check the ownership.

The application's error messages can reveal whether the ownership check exists. If I use B's identifier and receive a different error message than when I use A's identifier, the server may have processed the request differently. I record those differences because they help me understand the backend logic.

Fixing and monitoring access control

The fix is usually to centralize authorization. Every endpoint that operates on a resource should call the same authorization component, and that component should compare the authenticated user with the resource owner. The check should happen on the server, not in the frontend. Role checks should also be centralized so that a missing if in one controller cannot create a vertical escalation.

Monitoring is just as important as fixing. I recommend logging denied access attempts and unusual patterns, such as one account requesting many identifiers owned by other accounts. The logs can catch attacks before they cause serious damage. Automated tests should include negative cases, where the request is sent with a different account and the expected response is a denial.

Mistakes I still make

My biggest mistake is stopping after the first 403. A 403 can hide a partial leak, a timing difference, or a side effect that the response does not show. I now check the response body and the application state after every denial. My second mistake is testing only the read endpoint. The update and delete endpoints often have weaker checks. My third mistake is forgetting to test the same endpoint with different HTTP methods, because some frameworks allow method override.

The fourth mistake is not writing down the account table. When I switch between accounts, I sometimes send the wrong token and waste time analyzing a meaningless response. The fifth mistake is assuming that a UUID is a security control. It is not. The only reliable control is the server-side authorization check.

My quick checklist

  1. Map every resource type and the endpoints that operate on it.
  2. Create two same-role accounts and two different-role accounts.
  3. Record baseline responses for every endpoint.
  4. Swap identifiers across GET, POST, PUT, PATCH, and DELETE.
  5. Replay admin requests with a lower-role account.
  6. Compare full response bodies and side effects, not just status codes.
  7. Check update, delete, batch, and list endpoints as well as detail endpoints.
  8. Log and report the account, request, and response for every finding.

What I would do next time

Next time I want to build the resource map before I write any test. The map takes time, but it is the reason I find bugs instead of missing them. I also want to test every method on every endpoint instead of focusing on the read path. Access control is not a single test. It is a systematic comparison of what the application says I can do and what it actually allows me to do.