Category: Access Control

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

  • Broken Object Property Level Authorization (BOPLA) Explained

    Broken Object Property Level Authorization (BOPLA) Explained

    Most APIs get the first authorization check right. When you ask for record 88213, the server confirms that record is yours before it answers. Broken object property level authorization is the bug that lives one level deeper. The endpoint checks that you may touch the object, then hands back or accepts every field on that object without asking whether you may touch each one. So you read a property you were never meant to see, or you write a property you were never meant to change, just by adding or reading extra keys in the JSON.

    Object level versus property level

    It helps to put the two side by side, because they are siblings and people mix them up. Broken object level authorization, often called BOLA or IDOR, is about the whole object. You ask for someone else’s note by changing an id, and the server forgets to check ownership, so it hands you their note. The fix is an ownership check on the object.

    Broken object property level authorization assumes that check already passed. You are looking at your own note, your own profile, your own order. The failure is that the object has fields with different sensitivity, and the endpoint treats them as one flat blob. Some fields are yours to read and write. Some are internal, or admin only, or set by the system. When the code reads them all out together, or accepts them all in together, the per field rule is missing.

    • Object level (BOLA / IDOR): can this user access this object at all?
    • Property level (BOPLA): can this user read or write this specific field on an object they are already allowed to access?

    OWASP lists this as API3:2023, and it folds together two older names you may know: excessive data exposure on the read side, and mass assignment on the write side. Same root, two directions.

    The read side: excessive data exposure

    Picture Acme Notes, a typical SaaS app. A user opens their profile, and the front end calls the API to load it. The endpoint checks that the caller owns the profile, which it does, then serializes the whole user row and sends it back:

    GET /api/v1/users/me
    Authorization: Bearer <user token>
    
    200 OK
    {
      "id": 4471,
      "name": "Dana Ruiz",
      "email": "dana@example.com",
      "plan": "pro",
      "isVerified": true,
      "role": "user",
      "internalNotes": "flagged by billing, do not refund",
      "riskScore": 82,
      "referredBy": "partner_ad_channel_7"
    }

    The user interface only draws the name, email, and plan. The other fields are never shown on screen. But the API sent them anyway, and anyone can open the network tab or call the endpoint directly and read internalNotes, riskScore, and the rest. Nothing was hacked. The server just answered with more than the caller was allowed to see. The object check passed. The property check was never written.

    This is easy to ship by accident. A developer returns the model object, or writes SELECT *, and every column the database holds rides along in the response. The UI hides the extra fields, so in a browser it looks correct. The exposure only shows up when you read the raw JSON.

    The write side: mass assignment

    The same flat handling on the way in gives you mass assignment. The user edits their display name, and the client sends a PATCH. The endpoint confirms the user owns the record, then binds the incoming JSON straight onto the object and saves it:

    PATCH /api/v1/users/me
    Authorization: Bearer <user token>
    
    {
      "name": "Dana R.",
      "role": "admin",
      "isVerified": true,
      "plan": "enterprise"
    }

    The form on the page only lets you change your name. But the request is just JSON, and you can put anything in it. If the server does something like user.update(request.body), it happily writes role, isVerified, and plan along with the name. You just made yourself an admin, marked yourself verified, and upgraded your own plan, on an object you were allowed to edit. The object level check said yes. No field level check said no.

    The endpoint asked whether you could touch the object. It never asked whether you could touch each field on it. That gap is the entire bug.

    How to spot broken object property level authorization

    You find this bug by comparing what a field is against what the endpoint lets you do with it, one field at a time. A few concrete moves:

    • Read the full response, not the rendered page. Call the endpoint directly and look at every key in the JSON. For each one, ask whether this user role should be allowed to see it. Fields like internalNotes, riskScore, role, or anything that looks system set are the ones to question.
    • Send fields the form never offered. On any write endpoint, add extra keys to the body: role, isVerified, ownerId, balance, isAdmin. Then read the object back and check whether the value stuck.
    • Look for the shape of the code. A response built from a whole model, or a write that binds the entire request body, is the tell. The safe versions name each allowed field explicitly.

    This is the kind of check that belongs in any review of your access control logic, right next to the object level ownership checks you already run.

    How to prevent it

    The fix is the same idea on both sides: decide, per field, what each role may read and write, and enforce it instead of trusting the object level check to cover everything.

    • Use explicit output schemas. Do not serialize the raw model. Define a response shape that lists exactly the fields this caller may see, and build the response from that. If a field is not in the schema, it cannot leak.
    • Allowlist writable fields. Never bind the whole request body. Accept a named set of fields the user may change, and drop everything else. role and isVerified should never be in that set for a normal user.
    • Separate views by role. An admin may need internalNotes. A normal user must not. Different roles get different schemas, checked on the server, not hidden by the client.
    • Test the property level, not just the object level. Add a test that a normal user cannot read the hidden fields and cannot write the privileged ones, even when they own the object.

    None of this is exotic. It is the discipline of treating each property as its own access decision, instead of letting one object level check speak for the whole record.

    Broken object property level authorization survives because it hides behind a check that already passed. The object was yours, so the code stopped asking questions, and the extra fields slipped through in both directions. This is exactly the kind of assumption based flaw an autonomous security researcher is built to find, because it comes from testing what an app quietly trusts rather than replaying a fixed list of payloads. You can read more about that approach on our about page.

    Frequently asked questions

    What is broken object property level authorization?

    It is an API access control flaw where the server checks that you can reach an object but not each individual property on it, so you can read fields you should not see or change fields you should not control. It is listed as API3 in the OWASP API Security Top 10.

    How is BOPLA different from BOLA or IDOR?

    BOLA and IDOR are about reaching a whole object that belongs to someone else. BOPLA is about the properties inside an object you can already reach. You own the record, but the app lets you read or write a field on it that should be off limits, like a role or a balance.

    What are the two main forms of BOPLA?

    Excessive data exposure, where a response returns properties the user should never see, and mass assignment, where a request writes properties the user should never set, such as isAdmin or isVerified.

    How do you prevent broken object property level authorization?

    Validate every property on both read and write against the caller’s role, use explicit allow lists for which fields can be returned or updated, and never bind a request body straight onto your data model.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Passkeys vs Passwords: What Actually Changes for Your Security

    Passkeys vs Passwords: What Actually Changes for Your Security

    If you have logged into anything new lately, you have probably been asked to create a passkey instead of a password. The pitch sounds nice, but the choice can feel murky. This post lays out passkeys vs passwords in plain terms, so you can decide whether to switch without taking anyone’s marketing on faith.

    What a password actually is

    A password is a shared secret. You pick a string, the site stores a scrambled version of it, and every time you log in you send that string so the site can check it. That model has one stubborn flaw: the secret leaves your hands. It travels to the site, sits in the site’s database, and often gets typed into whatever page asks for it.

    That single fact explains most of the trouble. If the database leaks, attackers get the scrambled passwords and crack the weak ones offline. If you reuse one password across ten sites, one breach exposes all ten. And if a fake login page asks nicely, plenty of people hand the secret straight to the attacker. None of this means people are careless. The design simply asks a human to keep a long secret and never give it to the wrong party, which is hard to do every single time.

    What a passkey is instead

    A passkey is a public private key pair tied to your device. When you create one, your phone or laptop generates two matched keys. The private key never leaves the device. The site only ever sees the public key, which is useless on its own. There is no shared secret to steal.

    Logging in works like a challenge and response. The site sends a random challenge, your device signs it with the private key, and the site checks that signature against the public key it stored. To access the private key you use your fingerprint, face, or a device PIN. That biometric stays on the device too. It is a local gate, not data sent to the site.

    The core shift is simple. Passwords prove who you are by sending a secret. Passkeys prove who you are by signing a challenge, so nothing worth stealing ever touches the site.

    Passkeys vs passwords on the attacks that actually hurt

    Here is where the comparison stops being abstract. Three of the most common ways accounts get taken over lose most of their power against passkeys.

    • Password reuse. A passkey is unique to each site by design, generated fresh per account. There is no single secret to reuse, so one leaked site cannot open another.
    • Phishing. A passkey is bound to the real site’s domain. The signature only works for the domain it was made for. A lookalike page at yourbanksecurelogin.com cannot collect a signature it can replay against the real bank, because the browser will not sign for the wrong origin.
    • Credential stuffing. This attack takes username and password pairs from old breaches and tries them everywhere. With no password stored anywhere and no secret to dump, there is nothing to stuff.

    This is also why passkeys count as strong two factor by themselves. Something you have, the device holding the private key, plus something you are, the biometric that authorizes it. Worth knowing how that fits the broader picture of authentication vs authorization: passkeys make proving who you are much harder to fake, but they do not decide what you are allowed to do once you are in. That second job still belongs to the app.

    The honest tradeoffs

    Passkeys are a real improvement, not a finished story. There are rough edges, and pretending otherwise would not help you decide.

    Losing the device

    If the private key lives only on one phone and that phone goes in a river, can you still get in? The answer depends on whether your passkey syncs. Platform passkeys from Apple, Google, and Microsoft back up to your account and restore to a new device. A passkey stored only on a single hardware key does not. So your recovery story is only as good as your backup, and you should set that up before you need it.

    Recovery still leans on older methods

    When you cannot use your passkey, most sites fall back to email or a text message code. That fallback can be the weak link. A text message code can be intercepted through SIM swapping, where an attacker convinces a carrier to move your number to their phone. Passkeys raise the front door, but if the back door is a texted code, the account is only as safe as that path. Prefer recovery through a synced account or a second passkey over a text whenever the site lets you.

    Sync across platforms is still uneven

    A passkey made on an iPhone syncs cleanly across Apple devices. Moving it to a Windows laptop or an Android tablet is smoother than it was, often by scanning a QR code with your phone to approve the sign in, but it is not always one tap. If you live across two ecosystems, expect a few moments where the flow asks you to reach for your phone.

    Not every site supports them yet

    Adoption is wide but not total. You will keep some passwords around for a while, which means a password manager is still useful for the accounts that have not caught up.

    So should you switch?

    For most people, yes, and you do not have to do it all at once. A reasonable plan looks like this:

    • Turn on passkeys for your highest value accounts first: email, banking, and your password manager itself. Email matters most because it is the reset path for everything else.
    • Keep your existing strong, unique passwords as a fallback where the site still requires one. Do not delete them yet.
    • Make sure your passkeys sync to a backup you control, so a lost phone is an annoyance and not a lockout.
    • Check the recovery options on each account and move away from text message codes where you can.

    The thing to hold onto is the underlying change. Passwords ask you to guard a secret and never hand it to the wrong party. Passkeys remove the secret from the equation, so a whole category of common attacks simply has nothing to grab. That is a genuine step forward, and the tradeoffs are about recovery and convenience, not about whether the security is sound.

    Stronger login is one layer. The deeper risks usually live in how an app decides what a logged in user may do, the kind of logic flaw that no passkey can cover. Finding those takes understanding how an app is meant to work and testing the assumptions it makes, which is the problem UnboundCompute is built to study.

    Frequently asked questions

    What is the core difference in passkeys vs passwords?

    A password is a shared secret you type and the site stores. A passkey is a key pair where the private key never leaves your device and the site only keeps the public half. Nothing secret is sent or stored on the server, so there is nothing to steal in a breach.

    Are passkeys really phishing resistant?

    Yes. A passkey is bound to the real site it was created for, so it will not sign in on a lookalike domain. Even a convincing fake page cannot trigger your passkey, which removes the main way passwords get stolen.

    What happens if I lose the device that holds my passkey?

    Most platforms sync passkeys to your account so a new device picks them up after you sign in. Keep your platform account recoverable and add a second method, since recovery is the main tradeoff in passkeys vs passwords.

    Do passkeys work across different platforms?

    Support is wide but not perfect. Passkeys sync cleanly inside one ecosystem, and cross platform use is improving through QR based sign in from a nearby phone. For a few services you may still keep a password as a backup for now.

    Should I switch to passkeys?

    Turn them on for your most valuable accounts first, such as email and banking, since those gain the most from phishing resistance. Keep a recovery method in place, and let the rest of your accounts move over as each site adds support.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: Password Strength Analyzer lets you measure how a password actually holds up against guessing. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • What is a Mass Assignment Vulnerability? How Extra Fields Break Access Control

    What is a Mass Assignment Vulnerability? How Extra Fields Break Access Control

    Most web frameworks make it easy to turn a request body into an object. You send some JSON, the framework copies every field onto a model, and the model gets saved. A mass assignment vulnerability happens when that copy step is too trusting, so a user can set fields the form never showed them, like role, is_admin, or account_id. The result is an access control failure: a normal user edits a field that was meant to be off limits.

    What mass assignment is

    The bug goes by a few names. Rails calls it mass assignment. Some frameworks call it autobinding or object injection. The shape is always the same. An incoming request body is bound straight onto an object or a database model, and the binder accepts any key that matches a property on that object. The developer is thinking about the two or three fields the form sends. The model has more fields than that, and the binder does not know which ones the user is allowed to touch.

    Picture an invented app called Acme Notes. A user can edit their own profile. The profile model looks like this:

    # Profile model (server side)
    class Profile:
        id          # set by the server
        name        # user editable
        email       # user editable
        role        # "user" or "admin", set by an admin only
        is_admin    # boolean, set by the server only
        verified    # set after email confirmation
        account_id  # which tenant this profile belongs to
    

    The form on the settings page shows two inputs: name and email. So the developer wires up an endpoint that takes the request body and binds it onto the model in one line.

    The normal request versus the attack

    Here is the request the form is meant to send. A user updates their display name.

    PATCH /api/profile
    Content-Type: application/json
    Cookie: session=...
    
    {"name": "Dana Lee"}
    

    The server binds name onto the model and saves. Nothing surprising. Now the attacker opens the developer tools, sees the request, and adds a field the form never offered.

    PATCH /api/profile
    Content-Type: application/json
    Cookie: session=...
    
    {"name": "Dana Lee", "role": "admin"}
    

    If the endpoint binds the whole body onto the model, role gets written along with name. The user just promoted their own account. The same trick works with {"is_admin": true}, with {"verified": true} to skip email confirmation, or with {"account_id": 7} to move their profile into another tenant. The attacker does not need to guess a hidden URL or break the session. They send one extra key on an endpoint they are already allowed to call.

    The form decides what a user sees. The model decides what a user can change. When those two lists drift apart, the gap is the vulnerability.

    Why a mass assignment vulnerability is really broken access control

    It is tempting to file this under input validation, but that misses the point. The data is valid. role: "admin" is a real value the field accepts. The problem is authorization: this user is not allowed to set that field, and the server never checked. That is why a mass assignment vulnerability sits inside the broader family of broken access control bugs.

    The OWASP API Security project names this directly. It calls the pattern Broken Object Property Level Authorization, which merges the older idea of mass assignment with excessive data exposure. The rule it states is simple: authorize access to each property of an object, not just the object as a whole. Being allowed to edit your profile does not mean you are allowed to edit every field on your profile.

    This is close kin to broken object level authorization, also known as IDOR. IDOR is about reaching an object you should not reach. Mass assignment is about changing a property on an object you can reach but should not control. Both come down to a missing check, and both are worth studying together in the wider access control category.

    How to spot it

    You find a mass assignment vulnerability by comparing two lists: the fields the form shows, and the fields the model accepts.

    • Read the form, then read the model. List the inputs the user interface sends. Then look at the database model or the binding target behind the endpoint. Every field on the model that is not on the form is a candidate. role, is_admin, verified, balance, and account_id are the usual suspects.
    • Send extra guessed fields and watch the response. Against an app you own, add a likely field to the body and submit it. Then read the object back. If the value stuck, the binder accepted a field it should have ignored. A response that echoes the new role or is_admin is a confirmed finding.
    • Watch the quiet cases. Sometimes the response does not show the field, but the change still happened. Promote yourself with is_admin, then load a page that only admins can see. If it loads, the write went through even though the response gave nothing away.
    • Audit the binding call. Search the code for the line that turns the request body into a model. If it copies the whole body with no allowlist, that is the bug in source form.

    How to prevent a mass assignment vulnerability

    • Use an explicit allowlist of bindable fields. Name the exact fields the endpoint is allowed to write, and bind only those. name and email on the profile endpoint, nothing else. An allowlist fails closed: a new sensitive field added later is ignored until someone chooses to include it.
    • Separate input DTOs from database models. Bind the request to a small input object that holds only user editable fields, validate it, then copy the approved values onto the model by hand. The request never touches the model directly, so it can never reach role or is_admin.
    • Never bind the request straight to the model. The one line shortcut that copies the body onto the saved object is the root of this bug. Treat it as a code smell on any endpoint that handles a model with sensitive fields.
    • Mark sensitive fields read only or protected. Many frameworks let you tag fields as not mass assignable, or keep a denylist of protected attributes. Use it as a backstop, but prefer the allowlist, since a denylist forgets the field you add next year.
    • Authorize the property, not just the action. Setting role should run through the same permission check an admin screen would use. If the current user cannot promote others through the admin interface, they cannot do it through a stray JSON key either.

    These habits also block the related access control vulnerability patterns, since the fix is the same idea every time: decide what a given user is allowed to do, and check it on the server before the write lands.

    Why this rewards understanding the app

    You do not find mass assignment by replaying a fixed payload. You find it by understanding what the form is supposed to do, then asking what the model behind it can actually accept. The bug is an assumption the code makes, that the request body only ever contains the fields the form sent, and the way to find it is to test that assumption with one extra key.

    That is the kind of bug an autonomous researcher that tests an app’s assumptions is built to surface. It learns how an endpoint is meant to work, guesses where the binding is too wide, sends the extra field, and confirms the write before reporting anything. You can read more about that approach on our about page.

    Frequently asked questions

    What is a mass assignment vulnerability?

    It is a bug where a framework binds an incoming request body straight onto an object or database model, accepting any key that matches a field on that model. A user can then set fields the form never showed, such as role, is_admin, or account_id. Sending {"name":"Dana","role":"admin"} to a profile endpoint that only meant to take a name can promote the user’s own account. It is also called autobinding or object injection.

    Why is mass assignment an access control problem and not just input validation?

    The submitted data is valid. A value like role: "admin" is something the field genuinely accepts, so validation passes. The real failure is authorization: this user was never allowed to set that field, and the server did not check. The OWASP API Security project files this under Broken Object Property Level Authorization, which says you must authorize access to each property of an object, not just the object as a whole.

    How do you detect a mass assignment vulnerability?

    Compare the fields the form shows against the fields the model accepts. Any model field missing from the form is a candidate, especially role, is_admin, verified, balance, and account_id. Against an app you own, add a guessed field to the request body and read the object back to see if the value stuck. Watch the quiet case too: the response may hide the field while the write still happened, so confirm by loading a page that only the elevated state can reach.

    How do you prevent a mass assignment vulnerability?

    Use an explicit allowlist of bindable fields and bind only those, so any new sensitive field is ignored until someone opts it in. Separate input DTOs from database models, validate the DTO, then copy approved values onto the model by hand so the request never touches the model directly. Never bind the request straight to the model, mark sensitive fields read only or protected as a backstop, and run any change to a field like role through the same permission check an admin screen would use.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • What is a CORS Misconfiguration? How It Leaks Data

    What is a CORS Misconfiguration? How It Leaks Data

    Browsers block one site from reading another site’s responses by default. That rule is the same origin policy, and CORS is the controlled way to relax it. A CORS misconfiguration happens when a server relaxes that rule too far, so a malicious page can read responses meant only for the logged in user. The result is account data theft from inside the victim’s own browser session.

    The same origin policy first

    An origin is the triple of scheme, host, and port. https://app.acme.io:443 is one origin. http://app.acme.io is a different origin, and so is https://api.acme.io. The same origin policy lets a page send requests to another origin, but it stops the page’s JavaScript from reading the response unless that origin gives permission. So https://evil.example can fire a request at https://api.acme.io, but it cannot read what comes back. That read block is what protects your logged in data.

    CORS, Cross Origin Resource Sharing, is the mechanism that grants the read permission on purpose. The server answers with headers that tell the browser which other origins are allowed to read the response.

    What CORS relaxes and the headers involved

    Two response headers carry most of the weight:

    • Access-Control-Allow-Origin names the origin that is allowed to read the response. It can be a single exact origin or the wildcard *.
    • Access-Control-Allow-Credentials, when set to true, tells the browser it is allowed to send cookies and read the response even though the request carried the user’s session.

    That second header is the dangerous one. Without it, a cross origin request that includes cookies cannot be read by the calling page. With it, the calling origin can read authenticated responses. So the combination of a permissive Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true is where account data leaks.

    The browser is asking the server one question, may this other site read my logged in response, and a CORS misconfiguration answers yes to a site that should never hear yes.

    The CORS misconfiguration patterns that leak data

    Take an invented app, Acme Notes, with an API at https://api.acme-notes.io. Here are the bad patterns its team could ship.

    Reflecting the Origin header back

    The simplest mistake is to read the incoming Origin request header and echo it straight back into Access-Control-Allow-Origin. The server effectively trusts whatever origin asks. Watch what an attacker page at https://evil.example gets:

    GET /api/account HTTP/1.1
    Host: api.acme-notes.io
    Origin: https://evil.example
    Cookie: session=a1b2c3d4...
    
    HTTP/1.1 200 OK
    Access-Control-Allow-Origin: https://evil.example
    Access-Control-Allow-Credentials: true
    Content-Type: application/json
    
    {"email":"sam@acme-notes.io","plan":"pro","apiKey":"sk_live_9f2..."}
    

    The server reflected https://evil.example and allowed credentials. The victim’s cookie rode along, the server returned their account, and the attacker’s JavaScript can now read it. The email and API key are stolen.

    Wildcard combined with credentials

    You cannot legally pair Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers reject that pairing on a credentialed request. So teams that want both reach for reflection instead, which lands them back in the pattern above. The wildcard on its own is fine for truly public data, but the moment a route needs cookies, a wildcard cannot be the answer, and reflecting the origin is not a safe substitute.

    Trusting the null origin

    Some setups, like a sandboxed iframe or a request from a local file, send Origin: null. A server that allowlists the string null is trusting a value any attacker can produce from a sandboxed iframe:

    GET /api/account HTTP/1.1
    Host: api.acme-notes.io
    Origin: null
    Cookie: session=a1b2c3d4...
    
    HTTP/1.1 200 OK
    Access-Control-Allow-Origin: null
    Access-Control-Allow-Credentials: true
    

    An attacker hosts a page that loads a sandboxed iframe, which sends Origin: null, and the server hands back the credentialed response. Never put null on a trust list.

    Weak matching with endswith or startswith

    Allowlist checks built on substring logic almost always leak. A check like origin.endswith("acme-notes.io") looks tight, but it accepts more than the team thinks:

    # Intended allow: https://app.acme-notes.io
    # endswith("acme-notes.io") also accepts:
    https://evilacme-notes.io        # attacker registers this domain
    https://acme-notes.io.evil.example  # attacker subdomain, also ends in the string? no,
                                        # but startswith and contains checks fail here too
    

    The domain evilacme-notes.io ends with acme-notes.io, so the suffix check passes and the attacker controls that domain. A prefix check has the mirror flaw: startswith("https://acme-notes.io") accepts https://acme-notes.io.evil.example. A contains check is worse still. The fix is to compare against exact origin strings, not fragments.

    Why a misconfiguration lets a site read your data

    The attack does not need to steal a password. The victim is already logged in to Acme Notes, so their browser holds a valid session cookie. The victim then visits https://evil.example, perhaps from a link. That page runs JavaScript that calls https://api.acme-notes.io/api/account with credentials included. The browser attaches the Acme Notes cookie automatically because cookies are scoped to the destination, not the calling page. If the response carries a permissive Access-Control-Allow-Origin for evil.example plus Access-Control-Allow-Credentials: true, the browser lets the attacker’s script read the body. The script then ships the account data to a server the attacker controls. No phishing form, no malware, just one bad header pair.

    How to detect a CORS misconfiguration

    • Send odd origins and read the response. Against an app you own, send requests with Origin: https://evil.example, Origin: null, and an origin that shares a suffix like https://evilacme-notes.io. If any of them comes back reflected in Access-Control-Allow-Origin alongside Access-Control-Allow-Credentials: true, you have a finding.
    • Audit the origin check in source. Search the codebase for where Access-Control-Allow-Origin is set. If the value comes from the request Origin header, or from endswith, startswith, or contains matching, that is the bug in source form.
    • Check every credentialed route. List the routes that return user data with cookies. Each one should allow only exact, known origins.

    How to prevent a CORS misconfiguration

    • Keep a strict allowlist of exact origins. Hard code the full origins you trust, scheme and host and port, and compare with an exact string match. https://app.acme-notes.io either matches the list or it does not.
    • Never reflect an arbitrary Origin. If you echo the incoming origin, do it only after confirming it is on the allowlist, and send no CORS headers at all when it is not.
    • Do not combine the wildcard with credentials. For routes that need cookies, set one exact origin. Reserve Access-Control-Allow-Origin: * for genuinely public, non credentialed data.
    • Treat null as untrusted. Keep null off every allowlist. There is no safe reason to trust it for authenticated routes.
    • Scope cookies and use SameSite. Marking session cookies SameSite=Lax or Strict reduces what a cross origin call can carry, which limits the blast radius if a CORS rule slips.

    This bug sits next to other ways a trust boundary gets crossed, so it pairs well with reading about CSRF and the wider access control category. Our web security glossary defines the origin and credential terms used here.

    Why this rewards understanding the app

    You do not find a CORS misconfiguration by replaying a fixed payload. You find it by understanding which origins the app should trust, which routes return logged in data, and how the server decides what to put in Access-Control-Allow-Origin. The bug is an assumption, that only the real frontend would ever ask, and the way to find it is to test that assumption with origins the app never planned for. That is the kind of bug an autonomous researcher built to test an app’s assumptions is made to surface. You can read more about that approach on our about page.

    Frequently asked questions

    What is a CORS misconfiguration?

    It is a server setting that relaxes the browser’s same origin policy too far, so a site that should not be trusted can read responses meant for the logged in user. It usually comes from a permissive Access-Control-Allow-Origin value paired with Access-Control-Allow-Credentials: true. When that pairing is granted to an attacker controlled origin, the attacker’s JavaScript can read authenticated account data straight from the victim’s browser session.

    Why is reflecting the Origin header dangerous?

    Reflecting means the server reads the incoming Origin request header and echoes it back into Access-Control-Allow-Origin. That trusts whatever origin asks, including https://evil.example. Combined with Access-Control-Allow-Credentials: true, it lets any attacker page read the victim’s logged in response. Only reflect an origin after confirming it is on a strict allowlist, and send no CORS headers when it is not.

    Can Access-Control-Allow-Origin be a wildcard with credentials?

    No. Browsers reject Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true on a credentialed request. Teams that want both often switch to reflecting the origin instead, which reintroduces the leak. For routes that need cookies, set one exact origin. Reserve the wildcard for genuinely public data that carries no session.

    How do you prevent a CORS misconfiguration?

    Keep a strict allowlist of exact origins, comparing scheme, host, and port with an exact string match rather than endswith, startswith, or contains logic that lets evilacme.com slip through. Never reflect an arbitrary origin, never pair the wildcard with credentials, and keep null off every allowlist. Marking session cookies SameSite=Lax or Strict limits the damage if a rule slips.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: CORS Misconfiguration Checker lets you test an origin against a CORS policy and see whether it would be trusted. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • What is CSRF (cross site request forgery)?

    What is CSRF (cross site request forgery)?

    A csrf attack tricks a logged in user’s browser into sending a request they never meant to send. The browser attaches the victim’s session cookie automatically, so the target app sees a normal, authenticated request and acts on it. Cross site request forgery, often written CSRF, abuses the gap between who clicked and what the server thinks happened.

    What a csrf attack actually is

    CSRF works because of one browser habit: cookies travel with every request to the site they belong to. If you are logged into Acme Notes in one tab, your session cookie goes out with any request your browser makes to acmenotes.example, no matter which page or which site started that request.

    An attacker cannot read your cookie. They do not need to. They only need your browser to fire a request, and the browser supplies the cookie on its own. The server reads the cookie, sees a valid session, and trusts the request. This is the trap.

    CSRF is not about stealing your session. It is about borrowing it for one request while you are not looking.

    How the browser auto sends cookies

    Say you log into Acme Notes and get a cookie named session=abc123. From that point, every request to acmenotes.example carries Cookie: session=abc123. A form submit, an image load, a script, a redirect: the cookie rides along. The browser does not ask whether the page that triggered the request is Acme Notes or some random blog. That ambient cookie is what an attacker reaches for.

    A concrete example: changing a victim’s email

    Acme Notes lets a user change their account email by posting to /account/email with one field, new_email. The endpoint checks the session cookie and nothing else. That single weak assumption, the cookie alone proves intent, is all a csrf attack needs.

    The attacker builds a page and emails the victim a link, or hides it inside an ad. The victim, still logged into Acme Notes in another tab, opens the page. This form submits itself the instant the page loads:

    <!-- evilpage.example/win.html -->
    <form id="x" action="https://acmenotes.example/account/email" method="POST">
      <input type="hidden" name="new_email" value="attacker@evil.example">
    </form>
    <script>document.getElementById("x").submit();</script>

    No click is needed. On load, the browser posts to Acme Notes and attaches session=abc123 because the request goes to acmenotes.example. The server sees a valid session, updates the email to attacker@evil.example, and now the attacker can trigger a password reset and take the account. The victim saw a blank page.

    Why it works: ambient authority

    The flaw is ambient authority. The session cookie acts as standing permission that applies to any request, regardless of where the request came from. The server proves who you are but never checks whether you meant this. CSRF lives in that missing check.

    What makes a request CSRFable

    Not every endpoint is a target. A request is exposed when all three of these hold:

    • It changes state. Updating an email, transferring funds, deleting a note, adding an admin. Read only endpoints leak nothing useful through CSRF on their own.
    • It authenticates by cookie alone. If the session rides only in an auto sent cookie, the browser hands it over for free. Endpoints that require a token in a custom header are much harder to forge from another origin.
    • It is predictable. The attacker must know the method, the URL, and the field names in advance. POST /account/email with one field new_email is easy to guess and easy to forge.

    Flip any one of these and the attack gets harder. Defenses below break the second and third.

    Defenses against a csrf attack

    Synchronizer tokens (anti CSRF tokens)

    The server generates a random token tied to the session, embeds it in every form, and requires it back on every state changing request. The attacker’s page cannot read that token, because the same origin policy blocks it from reading Acme Notes pages, so the forged request arrives without a valid token and the server rejects it.

    # server side check, in plain pseudocode
    token_from_form = request.body["csrf_token"]
    token_for_session = session["csrf_token"]
    
    if not token_from_form or token_from_form != token_for_session:
        reject(403)   # missing or wrong token, drop the request
    else:
        process_email_change()

    Token randomness matters. The token must be long and unpredictable, drawn from a cryptographically secure random source and unique per session. If the token is a counter, a timestamp, or a hash of the username, the attacker can compute it and include it in the forged form. A guessable token is no protection at all.

    SameSite cookies

    Mark the session cookie SameSite=Lax or SameSite=Strict. The browser then withholds the cookie on cross site requests. With SameSite=Strict, a POST from evilpage.example to acmenotes.example carries no session cookie, so the forged request lands as an anonymous one and fails. Lax still blocks cross site POSTs while allowing top level navigations, which suits most apps. Set this, and also keep tokens, because older browsers and some flows still slip through. You can confirm a cookie actually carries SameSite, Secure, and HttpOnly with our free security headers and CSP analyzer.

    Checking Origin and Referer

    State changing requests carry an Origin header, and often a Referer, that name the page that started them. The server can reject any request whose Origin is not its own. A forged request from evilpage.example shows Origin: https://evilpage.example, which fails the check. Treat this as a second layer, not the only one, since a missing header should be handled with care rather than waved through.

    Why CORS is not a CSRF defense

    This one trips people up. CORS controls whether JavaScript on one origin may read the response from another origin. CSRF does not care about reading the response. The damage, changing the email, is done by the request itself the moment the server processes it. The attacker never needs to see the reply. A restrictive CORS policy does not stop the browser from sending the cross site request with cookies attached, so it does nothing against a csrf attack. Treat CORS and CSRF as separate problems. That said, CORS has its own failure mode in the other direction, where response headers expose authenticated data to any origin; our free CORS misconfiguration checker flags those dangerous combinations.

    A short checklist

    • Require an anti CSRF token on every state changing request, and make it random per session.
    • Set SameSite on session cookies.
    • Validate Origin on writes as a backup.
    • Do not lean on CORS for this. It guards reads, not writes.
    • Keep read endpoints read only, so a GET never changes state.

    Want more on the access boundaries attackers probe, from sessions to permissions? Read the access control posts.

    Closing

    CSRF is a logic gap, not a payload. The server trusts a cookie as proof of intent, and an attacker borrows that trust for one request. The fix is to prove intent on every write with an unpredictable token, withhold cookies on cross site requests, and check where the request came from. This is exactly the kind of assumption, the cookie alone means the user meant it, that an autonomous researcher built to test how an app really behaves is made to find. To see how UnboundCompute approaches that, read about.

    Frequently asked questions

    What is a CSRF attack?

    A CSRF attack tricks a logged in user’s browser into sending a request they never meant to send. The browser attaches the victim’s session cookie automatically, so the target app sees a normal authenticated request and acts on it. See the OWASP CSRF page for more background.

    How do you prevent CSRF?

    Require an anti CSRF token on every state changing request, drawn from a secure random source and unique per session, because the attacker’s page cannot read it. Set SameSite on session cookies so the browser withholds them on cross site requests, and validate the Origin header on writes as a backup layer.

    Does CORS protect against CSRF?

    No. CORS controls whether JavaScript on one origin may read the response from another origin, but CSRF does not care about reading the response, since the damage is done the moment the server processes the request. A restrictive CORS policy does nothing to stop the browser from sending a cross site request with cookies attached, so treat CORS and CSRF as separate problems.

    What makes a request vulnerable to CSRF?

    Three things have to hold at once. The request changes state, like updating an email or transferring funds, it authenticates by cookie alone, and it is predictable enough that an attacker can guess the method, URL, and field names in advance. Break any one of these and the attack gets much harder.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: Cookie Security Auditor lets you paste a Set-Cookie header and see which flags are missing. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • 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.

    Frequently asked questions

    What is broken object level authorization?

    Broken object level authorization is when the server hands back an object because the request asked for it, without checking that the caller is allowed to see that specific object. Any logged in user can then read or change objects that belong to other users just by naming their ids. See the OWASP API Security entry on this flaw.

    What is the difference between IDOR and broken object level authorization?

    They describe the same class of bug from two angles. IDOR, insecure direct object reference, is the older name and points at the exposed id that maps straight to a database record, while broken object level authorization names the missing ownership check behind it. In practice people use the terms interchangeably.

    How do you test for an IDOR or broken object level authorization bug?

    Create two test users, then while signed in as user A request user B’s objects by swapping the id, for example changing /api/orders/1001 to B’s /api/orders/1002 with A’s token. A 403 or 404 is correct, while a 200 OK with B’s data is the finding. Repeat for GET, PATCH, PUT, and DELETE, since read and write access fail separately.

    How do you fix broken object level authorization?

    Before returning or changing an object, confirm the authenticated caller is allowed to act on that exact object, on the server and in the data layer. Scope the lookup to the owner by fetching by id and user id together, centralize the check in one policy function, and do not rely on hard to guess ids alone since obscurity is not authorization.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • 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.

    Frequently asked questions

    What is privilege escalation in a web application?

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

    What is the difference between horizontal and vertical privilege escalation?

    Horizontal escalation means you stay at your own permission level but act as a different account at that same level, such as reading another member’s note by changing the id. Vertical escalation means you climb to a higher level than your account should have, such as a member becoming an admin. Both come from the same root cause, a missing server side check.

    How can I test my app for privilege escalation?

    Change an id in a request to one you do not own and see if data comes back, add fields like role or isAdmin to an update and see if the server keeps them, and call admin only routes with a plain member token. A 200 OK where you expected 403 is the bug. The OWASP Broken Access Control entry describes the underlying weakness.

    How do you prevent privilege escalation?

    Check authorization on the server, for every request, against the user making it. Deny by default so a forgotten guard fails closed, read role and ownership from your database record rather than the request body, and route every endpoint through one shared authorization layer.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • 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.

    Frequently asked questions

    What is an access control vulnerability?

    It happens when an application lets a user do something the app never meant to allow, because the server failed to check whether the user was permitted before acting on the request. The login can be perfect and the bug still exists, since access control is a separate gate that must be checked on every request that touches protected data. Broken access control sits at the top of the OWASP Top 10.

    What is the difference between horizontal and vertical access control?

    Horizontal access control keeps users at the same level inside their own data, so a regular user reaching another regular user’s record is a horizontal failure. Vertical access control separates levels of power, so a normal user reaching an admin only function is a vertical failure. Some bugs are both at once, like a weak endpoint that lets a regular user read other people’s data and trigger admin actions.

    Is IDOR the same as broken access control?

    IDOR, or insecure direct object reference, is one common shape of broken access control, not a separate thing. It happens when an id in a URL or request body is a direct pointer to an object and nothing stops a user from pointing at someone else’s. For example, changing /notes/1024 to /notes/1025 and getting back a note that is not yours is a broken object level access control bug.

    How do I prevent broken access control?

    Check authorization on the server, for every request, against the identity in the session, not against anything the client sent. Deny by default and open access on purpose per route, tie ownership to the session rather than trusting a user_id from the request body, and centralize the rules in one shared function. Then test it like a feature so a missing check cannot return quietly.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

  • 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. If that session travels as a JSON Web Token, you can decode it and check its algorithm, claims, and expiry with our free JWT security inspector.

    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.

    Frequently asked questions

    What is the difference between authentication and authorization?

    Authentication proves who you are, and authorization decides what you are allowed to do. Authentication runs once at login or per token using passwords, passkeys, or codes, while authorization runs on every protected action using ownership rules, roles, and permissions. You cannot decide what a user may do until you know who the user is, so authentication comes first.

    Does a 401 status code mean authentication or authorization failed?

    A 401 means authentication failed, even though it is literally named “Unauthorized,” so it really means you are not logged in yet. A 403 Forbidden means you are authenticated but not allowed to do this specific thing. The names read backwards from the concepts, so code that uses them interchangeably is often a sign the two ideas are blurred in the codebase.

    How do I test for a broken authorization check?

    Create two real users with separate data, log in as the first and note an object you own like /invoice/123, then log in as the second user and request that object directly by its id. If the second user sees the first user’s data, you found a broken authorization check, also called an insecure direct object reference. Repeat for write actions like POST and DELETE, not just reads. See OWASP Broken Access Control for more.

    Why is a valid session not enough to allow a request?

    A valid session only proves authentication, which answers who you are, but it never answers whether you may act on a specific object. A global middleware that confirms a session and then treats every request as allowed handles authentication and stops there. Authorization needs object level and field level rules, so a user may read their own profile but must not change their own role to admin.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.