Category: Access Control

Broken access control, authorization, IDOR, and the bugs that come from who can do what.

  • Broken object level authorization and IDOR, with examples

    Broken object level authorization and IDOR, with examples

    Broken object level authorization is the most common serious flaw in modern APIs, and it is easy to introduce by accident. The bug is simple to state: the server hands back an object because the request asked for it, without checking that the caller is allowed to see that specific object. This post explains broken object level authorization and its close cousin IDOR, shows a worked API example, and covers how to find the bug and how to fix it.

    What is broken object level authorization

    An API endpoint usually identifies a thing by an id. You ask for order 1001, the server looks up order 1001, and it returns the data. The missing step is the check that this order belongs to you. When that check is absent, any logged in user can read or change objects that belong to other users just by naming their ids. That is broken object level authorization.

    What is IDOR

    IDOR stands for insecure direct object reference. It is the older name for the same idea. A direct object reference is when the id in the request maps straight to a record in the database, like a row primary key. The reference is insecure when the server trusts it without an ownership check. So IDOR describes the exposed id, and broken object level authorization describes the missing check behind it. In practice people use the two terms for the same class of bug.

    The id in the URL tells the server which object to fetch. It must never decide who is allowed to fetch it.

    A broken object level authorization example

    Take an invented app, Acme Notes, that lets people place orders. A signed in user opens their order history and the browser calls this endpoint.

    GET /api/orders/1001
    Authorization: Bearer eyJhbGc...tokenForUserA
    
    200 OK
    {
      "id": 1001,
      "user_id": 42,
      "total": "38.00",
      "shipping_address": "12 Oak Street, Apt 4",
      "items": ["Notebook", "Pen set"]
    }

    The user owns order 1001, so this response is correct. Now they change one digit in the URL and send the same token.

    GET /api/orders/1002
    Authorization: Bearer eyJhbGc...tokenForUserA
    
    200 OK
    {
      "id": 1002,
      "user_id": 77,
      "total": "210.00",
      "shipping_address": "98 Pine Avenue",
      "items": ["Desk lamp", "Monitor stand"]
    }

    Order 1002 belongs to user_id 77, a different person. User A is still authenticated, and the server still returned the record. The token proved who the caller is. Nothing proved the caller owns this order. That gap is the whole bug. By walking the ids from /api/orders/1000 upward, the same caller can read every order in the system, including names, addresses, and totals.

    It is not only reads

    The same gap applies to writes. If the app exposes PATCH /api/orders/1002 or DELETE /api/orders/1002 with no ownership check, a user can edit or delete another person’s order. A profile endpoint like PUT /api/users/77/email with the same flaw lets an attacker take over an account by changing its recovery email. The id can also live in a request body or a query string, not just the path, so {"invoice_id": 1002} deserves the same scrutiny as a URL.

    Why APIs are especially prone to this

    Server rendered web apps often built one page that already filtered records to the current user. APIs split that into many small endpoints, and each one fetches objects by id on its own. Every endpoint becomes a separate place where the ownership check can be forgotten. A few reasons this class of bug keeps appearing:

    • Object ids are visible and guessable. Sequential integers like 1001 and 1002 advertise that 1003 exists. Even random ids do not fix the bug, they only make it harder to find by guessing.
    • The check is per object, not per route. Login and role checks happen once at the edge. Object ownership has to be checked on every single fetch, and it is easy to miss one endpoint out of fifty.
    • Frameworks do not add it for you. Most routing layers confirm the user is logged in. Very few know that order 1002 must belong to the caller. That logic is yours to write.
    • Nested and indirect references multiply the surface. Endpoints like /api/users/42/orders/1001/items/9 have several ids, and a check on one does not cover the others.

    How to detect it

    You find this bug by behaving like a real user with two accounts, then asking whether one account can reach the other’s objects.

    • Create two test users. Sign in as user A and as user B. Note the object ids that belong to each.
    • Swap the ids. While logged in as A, request B’s objects: change /api/orders/1001 to B’s /api/orders/1002 with A’s token. A correct server returns 403 Forbidden or 404 Not Found. A 200 OK with B’s data is the finding.
    • Repeat for every verb. Try GET, then PATCH, PUT, and DELETE on the same id. Read access and write access fail separately.
    • Check ids in every position. Path, query string, JSON body, and headers can all carry an object reference.
    • Watch for indirect leaks. A list endpoint, a search result, or an export job can hand back objects the caller should not see, even when the direct fetch is locked down.

    This is hard to catch with a scanner that only matches known payloads, because there is no payload. The request is well formed and the id is valid. Finding it means understanding what each object is, who should own it, and then testing that assumption directly. More on access control bugs is here.

    The fix: authorize every object on the server

    The cure is one rule applied everywhere: before returning or changing an object, confirm the authenticated caller is allowed to act on that exact object. Do this on the server, in the data layer, not in the client.

    The earlier example is fixed by scoping the lookup to the caller. Instead of fetching by id alone, fetch by id and owner together.

    def get_order(order_id, current_user):
        order = db.orders.find_one(
            id=order_id,
            user_id=current_user.id,   # ownership is part of the query
        )
        if order is None:
            return Response(status=404)
        return Response(order)

    Now order 1002 is invisible to user A, because the query asks for an order with that id that also belongs to A. Some practices that make this reliable across a whole codebase:

    • Scope queries to the owner by default. Filter by tenant or user id in the data access layer so an unscoped lookup is the exception, not the norm.
    • Centralize the check. Put authorization in one policy function each endpoint calls, so the rule is written once and reused, not copied and forgotten.
    • Return 404 for objects the caller cannot access. A 403 confirms the object exists. A 404 reveals less.
    • Do not rely on hard to guess ids alone. Random UUIDs reduce guessing, but the server must still check ownership. Obscurity is not authorization.
    • Write a test per endpoint. For each object route, add a test where user A requests user B’s object and asserts the response is denied. This keeps the bug from coming back.

    Broken object level authorization is a logic bug, not a string in a payload. Finding it means knowing what each object is, who should own it, and then proving the server agrees, which is exactly the kind of assumption an autonomous researcher that tests application logic is built to check. Read more about how UnboundCompute works.

  • What is privilege escalation? Examples explained

    What is privilege escalation? Examples explained

    Most web apps decide what you can see and do based on who you are. When an attacker breaks that decision and gains rights they were never granted, that is privilege escalation. It is one of the most common and most damaging classes of bug in modern web apps, and it usually hides in plain sight inside ordinary features.

    The good news is that the idea is simple once you see a few examples. Below we walk through what the bug looks like, the two main flavors, and how to spot and stop it in your own app.

    What is privilege escalation?

    Privilege escalation happens when a user performs an action or reads data that their account should not be allowed to touch. The app trusts the request without checking, on the server, whether this specific user is allowed. The attacker does not break the login. They log in as themselves and then reach further than their account permits.

    Think about a typical SaaS app we will call Acme Notes. Every user has a role, and every note has an owner. The rules are clear on paper. A regular member can edit their own notes. An admin can manage every account. Privilege escalation is what happens when the code never enforces those rules on each request.

    Authentication proves who you are. Authorization decides what you may do. Privilege escalation is the gap that opens when the second check is missing or wrong.

    The two kinds: horizontal and vertical

    Almost every case fits into one of two shapes. Both come from the same root cause, a missing server side check, but they reach different targets.

    Horizontal escalation: acting as another user at the same level

    Horizontal escalation means you stay at your own permission level but act as a different account at that same level. You are a member, and you reach into another member’s data.

    In Acme Notes, suppose the app loads a note like this:

    GET /api/notes/8841
    Authorization: Bearer (your real token)

    You own note 8841. Out of curiosity you change the number:

    GET /api/notes/8842

    If the server returns note 8842 and it belongs to someone else, the app never checked ownership. It saw a valid login and trusted the request. That is a classic example, often called an insecure direct object reference. The same flaw shows up on profile pages such as /api/users/1207/settings, on invoices, on file downloads, and anywhere an identifier appears in the URL or body.

    Vertical escalation: becoming an admin

    Vertical escalation means you climb to a higher permission level than your account should have. A member becomes an admin. Here are two simple invented examples.

    • Flipping a role field. Imagine the signup or profile update endpoint accepts the whole user object and saves every field it receives. You send your normal update but add one line:
      PATCH /api/users/me
      {
        "displayName": "Sam",
        "role": "admin"
      }

      If the server saves role straight from the request body, you just promoted yourself. This is a mass assignment bug, and it turns a profile form into an admin switch.

    • Hitting an admin only endpoint directly. The admin dashboard link is hidden from your navigation bar, so it feels protected. But the button only hides the link, it does not guard the route. You guess or read the path and call it yourself:
      POST /api/admin/users/3092/delete

      If the server runs the action because you are logged in, without checking that you are an admin, the hidden link was the only lock on the door.

    How it connects to broken access control

    Privilege escalation is the practical result of broken access control. Access control is the set of rules about who can do what. When those rules are checked in the browser only, or checked for some routes but forgotten on others, or written so that any logged in user passes, the control is broken. An attacker walks straight through the gap.

    The pattern repeats across apps because the checks are scattered. One endpoint verifies ownership, the next one nearby does not. A new feature ships without the guard the older feature had. You can read more in our access control category, where this family of bugs lives.

    How to spot it

    You find these bugs by questioning what the app assumes about you, then testing each assumption with a real request. A few concrete checks:

    • Change the identifier. Take any request with an id in the path or body and swap it for an id you do not own. If you get data back, you found horizontal escalation.
    • Add fields the form does not show. Send role, isAdmin, accountType, or ownerId in an update request and see if the server keeps them.
    • Call privileged routes as a low rights user. List every admin endpoint you can find and request each one with a plain member token. A 200 OK where you expected 403 is the bug.
    • Compare two accounts. Log in as a member and as an admin. Watch which checks the server applies to one and skips for the other.

    The mindset matters more than any single test. You are not throwing known payloads at the app. You are reading how the app expects to be used, then asking what happens when you step outside that expectation.

    How to prevent it

    Every fix comes back to one rule: check authorization on the server, for every request, against the user making it.

    • Check ownership and role on each request. Before returning note 8842, confirm the note’s owner matches the logged in user. Before running an admin action, confirm the caller is an admin. Do this on the server, never in the browser alone.
    • Deny by default. New routes should reject access until you explicitly allow it. A forgotten guard should fail closed, not open.
    • Never trust client supplied fields for permissions. Read role and ownerId from your database record for the session, not from the request body. Allow list the fields an update may change.
    • Use one shared authorization layer. When every route calls the same access check, you stop the slow drift where one endpoint is safe and the next one is not.
    • Test the negative case. Write tests that confirm a member gets 403 on admin routes and cannot read another member’s data. Run them on every change.

    Privilege escalation rarely announces itself. There is no crash and no error in the logs, just a request that succeeded when it should have failed. That quietness is exactly why testing the assumptions an app makes finds these bugs when a fixed list of payloads will not. It is the kind of flaw an autonomous researcher built to understand an app, form ideas about where its logic breaks, and verify each finding with real evidence is made to catch. If you want to see how we think about this, read more about UnboundCompute.

  • What is an access control vulnerability? Broken access control explained

    What is an access control vulnerability? Broken access control explained

    An access control vulnerability happens when an application lets a user do something the app never meant to allow. Broken access control is the name for this whole family of bugs, and it sits at the top of the OWASP Top 10 because it shows up everywhere and the damage is direct. If you have ever wondered what is access control vulnerability in plain terms, it is this: the server forgot to check whether you are allowed before it did what you asked.

    What is broken access control vulnerability, in one sentence

    Access control is the set of rules that decide who can see and change what. Authentication answers “who are you.” Access control answers “are you allowed to do this specific thing right now.” When that second check is missing, weak, or only enforced in the browser, you get a broken access control flaw. The user proves who they are, then reaches data or actions that should be off limits to them.

    Here is the part that surprises beginners. The login can be perfect. Passwords can be strong, sessions can be secure, and the bug still exists. Access control is a separate gate, and it has to be checked on every request that touches protected data.

    Why broken access control is the number one OWASP risk

    Three reasons put it first.

    • It is common. Almost every app has many endpoints, and each one needs its own check. Miss one and you have a hole.
    • It is easy to trigger. Many of these bugs need nothing more than a changed number in a URL or a flipped value in a request body. No special tools.
    • The impact is plain. Read another person’s records, delete data you do not own, or reach an admin function. There is no fancy exploit chain in between.

    If the server does not ask “is this user allowed to do this” on every request, the answer is no by accident.

    Three simple examples

    These use an invented app, Acme Notes, where people store private notes. None of this targets a real system.

    1. Changing an id in a URL. You open your own note and the address looks like this.

    GET /notes/1024
    Cookie: session=your_own_valid_session

    You change the number to a note that is not yours.

    GET /notes/1025

    If Acme Notes returns note 1025 without checking that it belongs to you, that is a broken object level access control bug. People often call this an insecure direct object reference, or IDOR. The id is a direct pointer to an object, and nothing stops you from pointing at someone else’s.

    2. Forcing your way to an admin page. The app hides the admin link from normal users, so the menu never shows it. But the route still exists.

    GET /admin/users

    You type the path by hand. If the server renders the admin user list because you happen to be logged in as anyone, the protection was only in the menu, not in the code that serves the page.

    3. Editing a request to act as another user. When you update your profile, the browser sends a body like this.

    POST /profile/update
    { "user_id": 1024, "email": "you@example.com" }

    You change user_id to someone else.

    POST /profile/update
    { "user_id": 1025, "email": "attacker@example.com" }

    If the server trusts the user_id in the body instead of the user tied to your session, you just changed a stranger’s email. The fix is to ignore that field entirely and use the identity from the session.

    Horizontal and vertical access control

    Two words help you reason about these bugs.

    Horizontal access control

    This is about users at the same level. You and another customer both have normal accounts. Horizontal access control keeps you inside your own data. The note id example above is a horizontal failure: one regular user reached another regular user’s note. The roles match, but the owner does not.

    Vertical access control

    This is about levels of power. A normal user should not reach actions reserved for an admin or a moderator. The admin page example is a vertical failure: a low privilege user reached a high privilege function. You climbed a level you were never granted.

    Many real bugs are one or the other. Some are both at once, like a regular user who can both read other people’s data and trigger admin only actions through the same weak endpoint.

    How to spot broken access control

    You find these bugs by asking, for every request, “what is being trusted here, and who set it.” Walk through the app with two accounts and try the obvious moves.

    • Change identifiers. Swap ids in URLs, query strings, and request bodies. Try ids that belong to a second account you control. Watch for data that is not yours.
    • Visit hidden routes directly. List the admin and settings paths you can find, then request them as a low privilege user. A redirect or a 403 is good. A real response is a finding.
    • Replay actions across roles. Capture a request that only an admin should make, then send it from a normal session. If it works, vertical control is broken.
    • Look for client side gates. If a button is hidden but the underlying API still answers, the check lives in the wrong place.
    • Test every method. An endpoint might block GET but allow DELETE or PUT. Try them.

    The mindset that finds the most is understanding what the app assumes. The note example only works because the app assumes you will never edit the id. Question that assumption and the bug appears. You can read more grouped writing on this topic in the access control category.

    How to prevent broken access control

    The core rule is short. Check authorization on the server, for every request, against the identity in the session, not against anything the client sent.

    • Deny by default. Start with everything closed. Open access on purpose, per route, never by forgetting to block it.
    • Decide on the server. The browser can hide a button to keep the screen clean, but it can never be the gate. The real check lives in code the user cannot touch.
    • Tie ownership to the session. When loading note 1025, confirm the note’s owner matches the logged in user. Do not trust a user_id from the request body.
    • Centralize the rules. One shared function that answers “can this user do this action on this object” is easier to get right than checks copied into every handler.
    • Avoid guessable ids where you can. Random identifiers are not a real defense on their own, but they raise the cost of blind guessing while your checks do the work.
    • Test it like a feature. Write checks that try one user’s id from another user’s session and confirm they fail. Run them on every change so the gap cannot return quietly.

    Putting it together

    Broken access control is the number one OWASP risk because the bug is simple, common, and high impact, and each new endpoint is one more place to forget the check. Spotting it means thinking about what the app trusts. Preventing it means checking authorization on the server for every request, using the identity you control rather than the data the user sent.

    This is exactly the kind of bug an autonomous researcher that tests assumptions is built to find, because it lives in the gap between how an app is meant to work and what it actually allows. Learn more on the about page.

  • Authentication vs authorization, explained with examples

    Authentication vs authorization, explained with examples

    People mix these two words all the time, and the mix up causes real bugs. The difference between authentication vs authorization is simple once you see it: authentication proves who you are, and authorization decides what you are allowed to do. This post walks through both with a concrete login example, then shows how confusing them leads to broken access control.

    Authentication vs authorization in one sentence each

    Authentication answers the question “who are you?” You prove your identity, usually with a password, a passkey, or a one time code. When the app is satisfied, it knows it is talking to a specific user.

    Authorization answers a different question: “are you allowed to do this?” Once the app knows who you are, it checks whether that identity may read a record, edit a setting, or delete an account. Same user, different question.

    Authentication is the bouncer checking your ID at the door. Authorization is the staff checking whether your ticket lets you into the VIP room.

    A login is authentication

    You type an email and password into a SaaS app called Acme Notes. The server checks the password hash, sees it matches, and starts a session. That whole exchange is authentication. At the end of it the app is confident you are alice@example.com and nobody else. Nothing here has decided what Alice can touch yet.

    Opening a record is authorization

    Alice is now logged in. She clicks an invoice and the browser requests /invoice/123. The server has to answer a separate question before it returns anything: does invoice 123 belong to Alice? That check is authorization. If invoice 123 belongs to Bob, the correct answer is no, even though Alice is a fully authenticated, real user.

    The example that shows the gap

    Here is the request Alice’s browser sends after she logs in:

    GET /invoice/123 HTTP/1.1
    Host: app.acmenotes.example
    Cookie: session=alicevalidsessiontoken

    The session cookie is valid. Authentication passes. The dangerous question is what the server does next. A correct server loads invoice 123, checks the owner field against the session user, and returns the invoice only if they match. A broken server skips that check and returns the invoice to anyone who is logged in.

    Now Alice edits the URL by hand and asks for /invoice/124, then /invoice/125, walking the numbers up one at a time:

    GET /invoice/124 HTTP/1.1
    Host: app.acmenotes.example
    Cookie: session=alicevalidsessiontoken

    If the server returns Bob’s invoice because Alice’s session is valid, the app has confused authentication with authorization. Alice proved who she is. The app never checked what she is allowed to see. This is the most common shape of broken access control, often called an insecure direct object reference, or IDOR.

    Why the confusion is so easy to ship

    Login code gets careful attention. Teams test it, rate limit it, and add multi factor. So authentication tends to be solid. Authorization is spread across every endpoint that returns or changes data, and it is invisible when you test with a single account, because that account owns everything it can reach. The bug only appears when a second user asks for the first user’s data. Many test suites never try that, so the gap survives to production.

    Authentication vs authorization, side by side

    • Question asked. Authentication: who are you? Authorization: what may you do?
    • When it runs. Authentication runs once at login or per token. Authorization runs on every protected action.
    • What proves it. Authentication uses passwords, passkeys, or codes. Authorization uses ownership rules, roles, and permissions.
    • Typical failure. Authentication failing lets a stranger become a user. Authorization failing lets a real user reach data that is not theirs.
    • Where it lives. Authentication sits at the front door. Authorization sits at every record, field, and button behind it.
    • Status code on denial. Authentication problems return 401 Unauthorized. Authorization problems return 403 Forbidden.

    The status codes are worth a closer look, because their names are backwards from the concepts. The 401 code is literally named “Unauthorized” but it means you are not authenticated, so log in. The 403 code means you are authenticated but not authorized for this thing. If your code uses these interchangeably, that is often the first sign the two ideas are blurred in the codebase too.

    “Authentication and authorization difference” in plain terms

    If you search for the authentication and authorization difference, you will see them paired constantly, sometimes shortened to authn and authz. They run in order. Authn first, because you cannot decide what a user may do until you know who the user is. Authz second, on every single request that touches protected data. Reverse them or skip the second step and you get the invoice bug above.

    How to spot the gap before an attacker does

    You do not need fancy tooling to start. You need two accounts and a habit of suspicion.

    • Create two real users, Alice and Bob, with separate data.
    • Log in as Alice and note an object you own, like /invoice/123.
    • Log in as Bob and request Alice’s object directly by its id.
    • If Bob sees Alice’s data, you found a broken authorization check.
    • Repeat for write actions, not just reads. A POST or DELETE to another user’s object is worse than a read.

    Then push past predictable ids. Swap a numeric id for a UUID and the manual walk gets harder, but the missing check is still missing. The fix is the same in every case: every endpoint must check that the current authenticated user is allowed to act on the specific object, on the server, on every request. Never trust the client to hide a button or skip a URL.

    The vs authorization vs authentication ordering trap

    Some teams write a global middleware that confirms a valid session, then treat every authenticated request as fully allowed. That handles authentication and stops there. Authorization needs object level and field level rules that the middleware cannot know. A user may be allowed to read their own profile but not change their own role to admin. Same identity, different permissions, decided per action.

    If you only remember one thing about authentication vs authorization, make it this: proving who you are is not the same as being allowed, and the gap between them is where access control breaks. For more on this class of bug, see our access control articles. Tracking down a missing ownership check across hundreds of endpoints is exactly the kind of bug an autonomous researcher that tests an app’s assumptions is built to find. You can read how we approach that on our about page.