Category: Access Control

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

  • OIDC Authentication Bypass: Trusting a Token You Never Verified

    OIDC Authentication Bypass: Trusting a Token You Never Verified

    In late June 2026 CISA added a new entry to its Known Exploited Vulnerabilities catalog: CVE-2026-48558, a CVSS 10.0 flaw in SimpleHelp, a widely deployed remote monitoring and management tool. It was already being used in the wild to plant infostealers and other malware. The root cause was not a memory bug or a clever injection. It was a plain OIDC authentication bypass: the login code read the identity claims inside a token and trusted them, but it never checked that the token was signed by anyone real. This post pulls that mechanism apart on a safe, invented example so you can spot the same shape of bug in your own app.

    What OIDC tokens actually promise

    OpenID Connect sits on top of OAuth and gives an application a way to say “this request belongs to this person.” When a user signs in, an identity provider mints an ID token. That token is a JWT, a JSON Web Token, made of three parts joined by dots: a header, a payload, and a signature. The payload carries identity claims like sub (the user id), email, and often groups for role or team membership.

    The important word is signed. The identity provider signs the token with a private key. It publishes the matching public keys at a JWKS endpoint, usually found through /.well-known/openid-configuration. A relying party, meaning the app receiving the token, is supposed to fetch those keys and verify the signature before it believes a single claim. The signature is the only thing that separates a token the provider issued from a string of JSON an attacker typed by hand.

    If you want the deeper split between proving who someone is and deciding what they may do, we cover it in authentication versus authorization. OIDC lives on the authentication side, and this bug lived there too.

    The OIDC authentication bypass, claim by claim

    Here is a decoded ID token payload for a made up admin panel we will call Acme Console. Nothing here is secret. The payload is base64url, not encryption, so anyone holding the token can read it:

    {
      "iss": "https://id.acme-console.example/",
      "aud": "acme-console",
      "sub": "9f14c2",
      "email": "tech@acme-console.example",
      "groups": ["support-technicians"],
      "exp": 1782000000
    }

    A correct login flow does two things with this token. First it proves the token is genuine by checking the signature against the provider’s JWKS. Only then does it read the groups claim and decide the session is a support technician. The vulnerable pattern skips straight to the second step. In pseudo code, the flaw looked like this:

    const parts = token.split(".");
    const payload = JSON.parse(base64UrlDecode(parts[1]));
    
    const session = provisionSession({
      email:  payload.email,
      groups: payload.groups,   // trusted as is
    });
    // parts[2], the signature, is never checked

    Read that again. The code splits the token, decodes the middle part, and builds a session from whatever it finds. The third segment, the signature that is the entire point of a JWT, is never fetched, never compared, never used. Any attacker who knows the token’s expected shape can write their own payload, set groups to the administrator group, attach any garbage after the second dot, and send it. The app reads the claims, sees an admin, and hands over an admin session. No password, no second factor.

    The token was treated as an identity document. It was never checked against the seal that makes it one. Reading a claim is not the same as verifying it.

    In SimpleHelp’s case the affected path was OIDC login configured with group authenticated login. An unauthenticated attacker could forge a JWT, sail past multi factor authentication, and impersonate any technician account, up to and including privileged administrators. The vulnerability class is catalogued as CWE-347, Improper Verification of Cryptographic Signature. Affected builds were 5.5.15 and prior, plus 6.0 prerelease builds. Internet scans found roughly 14,000 exposed SimpleHelp servers, of which about 1,000 were directly vulnerable. On an RMM tool, an admin session is the keys to every managed endpoint, which is why this became a malware delivery route so quickly.

    The cousin bug: trusting the header

    There is a related failure worth naming. Even code that does call a verify function can be tricked if it lets the token’s own header pick the algorithm. An attacker sets alg: none to claim the token needs no signature, or swaps a strong asymmetric algorithm for a weak one the server verifies with the wrong key. That is JWT algorithm confusion, and it lands in the same place as a missing check: a forged token accepted as real.

    How to spot it in your own app

    You do not need the source to test for this. You need three quick observations:

    • Alter the signature and see if login still works. Take a valid token, flip a few characters in the third segment, and present it. A correct app rejects it outright. A vulnerable one logs you in, which proves the signature is decoration.
    • Watch for a JWKS fetch. A relying party that verifies signatures has to fetch the provider’s public keys. If the app never calls the JWKS or discovery endpoint during login, it has no key to verify against, so it cannot be verifying anything.
    • Try alg: none and edited claims. Craft a token with the algorithm set to none and a changed email or groups value. If it is accepted, the app is trusting the payload and, at best, trusting the header too.

    How to prevent it

    The fix is one habit, applied without exception: verify before you trust. A safe version of the Acme Console flow does the whole check in one call and refuses the token unless everything holds:

    const { payload } = await jwtVerify(token, JWKS, {
      issuer:   "https://id.acme-console.example/",
      audience: "acme-console",
    });
    // throws unless the signature, iss, aud, and exp all check out

    Concretely, that means:

    • Verify the signature against the issuer’s JWKS before reading any claim. Fetch the published public keys and confirm the token was signed by the key the provider advertises.
    • Pin the accepted algorithms server side. Decide which algorithms you allow and reject everything else, so a token cannot talk you into none or a downgrade.
    • Validate iss, aud, and exp. A valid signature on a token meant for a different app, or one that expired last year, is still the wrong token. Confirm it was issued by your provider, for your app, and is still in date.
    • Reject unsigned tokens. There is no legitimate reason to accept a JWT with no signature in a login flow. Treat an absent or empty signature as an immediate failure.

    Every one of these is standard in mature OIDC libraries. The bugs show up when a team hand rolls the token parsing, or turns verification off during testing and forgets to turn it back on. For more on this family of failures, see our access control writing.

    The assumption that broke

    The failure was not cryptography. The math was fine and the provider signed everything correctly. The app simply never asked to see the proof. It read a claim that said “administrator” and believed it, the same way it would believe any string it was handed. This is the whole story: a token was trusted for what it said, not for what it could prove. That gap between reading a claim and verifying it is exactly the kind of assumption an autonomous security researcher that tests an application’s beliefs and backs findings with hard evidence is built to catch, before someone else forges the token first. More on how that works on our about page.

    Frequently asked questions

    What is an OIDC authentication bypass?

    It is a login flaw where an app reads the identity claims inside an OIDC token but never verifies the token’s signature. Because the claims are trusted without proof, an attacker can forge a token and be accepted as any user, including an administrator.

    Why is the signature the important part of a JWT?

    The payload of a JWT is base64url encoded JSON that anyone holding the token can read or rewrite. The signature is the only thing that proves the identity provider issued it. Skip the signature check and the claims prove nothing.

    How do you test for it?

    Take a valid token, change a few characters in the signature segment, and try to log in. A correct app rejects it outright. If login still works, the signature is not being checked. Presenting a token with the algorithm set to none is a fast second test.

    How do you prevent an OIDC authentication bypass?

    Verify the signature against the issuer’s published keys before reading any claim, pin the algorithms you accept so a token cannot request none, validate the issuer, audience, and expiry, and reject unsigned tokens.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • JWT Algorithm Confusion Attacks Explained

    JWT Algorithm Confusion Attacks Explained

    Many web apps hand you a token after you log in, and that token is what proves who you are on every later request. When the server checks that token the wrong way, an attacker can rewrite what it says and walk in as someone else. JWT algorithm confusion is the name for a family of bugs where the server lets the token itself decide how it should be verified, and that one mistake turns a signed proof of identity into a form the attacker can fill in.

    How a JSON Web Token is built

    A JWT is three parts joined by dots: header.payload.signature. Each of the first two parts is a JSON object encoded with base64url, which is just a text safe way to carry bytes. The third part is a signature computed over the first two.

    Say a fictional app called Acme Notes issues this token when you log in. Decoded, the header and payload look like this:

    header
    { "alg": "RS256", "typ": "JWT" }
    
    payload
    { "sub": "1042", "role": "user", "exp": 1893456000 }

    The header says which algorithm signed the token. Here it is RS256, which uses a private RSA key to sign and the matching public key to verify. The payload holds claims: this user has id 1042 and the role user. The signature is the part that is supposed to make the payload impossible to change, because only Acme Notes holds the private key that can produce a valid one.

    The whole point is trust. When Acme Notes gets a token back, it verifies the signature. If it checks out, the server believes the claims and lets you read your notes. Change one character in the payload and the signature no longer matches, so the token is rejected. That is the design working as intended.

    Why jwt algorithm confusion happens at all

    Here is the root cause. The alg field lives inside the header, and the header is part of the token the client sends, so the attacker controls it. If the verifying code reads alg from the token and trusts it to pick the verification method, the attacker gets to choose how their own token is checked. That is the whole bug in one sentence: the thing being verified is telling the verifier how to verify it.

    A safe server ignores what the token claims and pins the algorithm on its own side. A vulnerable server asks the token what to do. Two named variants of this show up again and again.

    The “alg”: “none” acceptance bug

    Early JWT libraries supported an algorithm literally called none, for cases where a token was already protected some other way. A token using it has an empty signature. So the attacker takes a real token, sets the header to { "alg": "none", "typ": "JWT" }, edits the payload freely, and drops the signature, keeping the trailing dot:

    { "alg": "none" }.{ "sub": "1042", "role": "admin" }.

    If the server sees none and concludes there is nothing to verify, it accepts the token and reads the claims as gospel. The attacker just changed "role": "user" to "role": "admin" with no key, no signature, and no secret. This is a plain authentication and authorization bypass.

    The RS256 to HS256 key confusion

    This one is quieter and it catches teams that think they did the right thing. RS256 is asymmetric: sign with a private key, verify with a public key, and that public key is meant to be shared openly. HS256 is symmetric: the same secret both signs and verifies.

    Now picture a verify call that reads the algorithm from the token and passes in the RSA public key as the key material. When the token says RS256, the library treats that key as a public key and all is well. But if the attacker changes the header to HS256, some libraries then treat the same bytes as an HMAC secret. The public key is not secret. The attacker already has it, or can often fetch it from a public endpoint. So they compute a valid HS256 signature over any payload they like, using the public key as the HMAC secret, and the server verifies it against that same key and accepts it.

    attacker starts from
    header  { "alg": "RS256" }
    payload { "sub": "1042", "role": "user" }
    
    attacker sends
    header  { "alg": "HS256" }
    payload { "sub": "7", "role": "admin" }
    signature = HMAC_SHA256(header.payload, RSA_public_key)

    The forged token is signed with a key everyone is allowed to have. The claims now say the attacker is user 7 with the admin role, and the server has no reason to doubt it, because the signature is valid under the only key it uses.

    The signature was never the weak point. The weak point was letting the token choose which kind of signature it was.

    What the forged claims actually buy

    Once the attacker can rewrite claims and still pass verification, the token becomes an editable identity card. A few common results:

    • Privilege escalation. Flip "role": "user" to "role": "admin" and reach pages and actions meant for staff.
    • Account takeover by id. Change "sub" from your own id to another user’s and read or change their data.
    • Longer sessions. Push "exp" far into the future so a token never expires.
    • Tenant crossing. In a multi tenant SaaS app, edit an organization id claim to see another customer’s records.

    None of this touches a password. The login step was fine. The failure is entirely in how the server decided to believe the token on the way back in. This is why these bugs sit squarely in the access control space rather than in cryptography. The math held. The trust decision did not.

    How to spot it and shut it down

    Defending against jwt algorithm confusion is short work, and none of it is optional. Each step removes the attacker’s ability to pick the rules.

    • Pin the algorithm on the server. Tell your verify call exactly which algorithm to accept, for example RS256 only, and reject anything else. Do not read alg from the token to decide. This is the single most important fix.
    • Reject “none” outright. Never allow an unsigned token in a system that relies on signatures. Most modern libraries block it by default, but confirm it rather than assume it.
    • Keep the key types apart. The code path that verifies asymmetric tokens should be physically unable to fall back to using a public key as an HMAC secret. Separate keys, separate functions, no shared entry point that switches on the header.
    • Use a maintained library and read its verify options. Ask it for an explicit allow list of algorithms. Older versions of several libraries had exactly these gaps.
    • Test the tampering directly. Send a token with alg set to none, and send an RS256 token re signed with the public key under HS256. A correct server rejects both. If either gets in, you have the bug.

    The clean rule to remember: the token is input from the client, and input never gets to choose how it is checked. The server picks the algorithm and holds the keys, and the token only carries claims that survive that fixed check.

    The assumption underneath

    Strip these two attacks down and the same shaky belief is left. The server assumed the token would honestly describe how to verify it. An attacker who controls the header simply lies. Pinning the algorithm and separating keys removes the lie’s payoff. This is exactly the kind of trust assumption an autonomous researcher is built to test, by asking what a system takes on faith and whether that faith holds when someone edits the part they were handed. An early signal we find encouraging: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Read more on our about page.

    Frequently asked questions

    What is a JWT algorithm confusion attack?

    It is an authentication bypass where the server trusts the token’s own alg header to decide how to verify the signature. An attacker changes alg so the server verifies a token the attacker can forge, then edits claims like the user id or role.

    What is the alg none attack?

    Some libraries accept a token with alg set to none and no signature at all. If the server does not reject it, an attacker can send an unsigned token with any claims and be trusted as any user.

    What is RS256 to HS256 key confusion?

    RS256 verifies with a public key. If an attacker switches alg to HS256, a vulnerable server verifies the token using that public key as the HMAC secret. The public key is not secret, so the attacker can sign a forged token that passes.

    How do you prevent JWT algorithm confusion?

    Pin the expected algorithm on the server instead of trusting the token header, reject none, and keep separate keys so a verification key can never be used as a signing secret.


    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.

    Try it yourself: JWT Security Inspector lets you decode a token and check it for the weaknesses described above. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Broken Function Level Authorization (BFLA) Explained

    Broken Function Level Authorization (BFLA) Explained

    Broken function level authorization is the bug where an API confirms that you are logged in but never checks whether your account is allowed to run the function you just called. So a normal user calls an admin action directly, and the server does it. The flaw sits at number five on the OWASP API Security Top 10 as API5:2023, and it is also called missing function level access control.

    What broken function level authorization actually is

    Most APIs get authentication right. You send a token, the server confirms the token is valid, and it knows who you are. The gap is the next step. Knowing who you are is not the same as checking what you are allowed to do. Broken function level authorization is what happens when that second check is missing on a specific endpoint, so any authenticated caller can reach a function that was meant for admins or another role.

    The word function is the important part. This class of bug is about actions and operations: promote a user, delete an order, export all invoices, change a price, disable an account. These are things the API can do. When the do side is not guarded by a role check, a low privilege account can perform work that should be off limits.

    A concrete example in Acme Notes

    Say Acme Notes is a typical SaaS app. Regular members can create notes and manage their own account. Admins can promote other members and remove users. The admin screen lives behind a nice UI that only shows up for admin accounts, and it calls this endpoint when an admin clicks Promote:

    POST /api/admin/users/1002/promote
    Authorization: Bearer <token for a normal member>
    
    HTTP/1.1 200 OK
    { "id": 1002, "role": "admin" }

    Notice the token. That is a normal member, not an admin. The frontend never shows this button to members, so the team assumed nobody without the admin UI would ever call the route. But the API is just an HTTP endpoint. Anyone who is logged in can send that request with a tool like curl. The server checked the token, saw a valid session, and ran the promote function. It never asked is this caller an admin. That single missing check is the whole vulnerability, and now a member can make themselves or a friend an admin.

    The hidden or guessed endpoint

    Admin routes often follow obvious patterns. If /api/users/1002 exists for normal reads, an attacker will try /api/admin/users/1002, /api/users/1002/promote, and /api/internal/users. They do not need documentation. They read the JavaScript bundle the app already served them, watch the network tab while a real admin works, or simply guess common names. If the guessed route runs without a role check, the fact that it was undocumented protected nothing. Security by obscurity is not access control.

    The HTTP verb variant

    The same endpoint can be safe for one method and wide open for another. A common pattern is a resource where reads are locked down but a destructive verb was never wired to a check. Consider an orders endpoint:

    GET /api/orders/8842        -> 200, returns your own order (checked)
    DELETE /api/orders/8842     -> 200, deletes it (never checked)

    The team carefully guarded the read path and forgot that DELETE on the same URL routes to a different handler. A normal user sends the delete and the order is gone, even one that belongs to someone else. Trying every method on a known path, GET, POST, PUT, PATCH, DELETE, is one of the first things a tester does, because verb by verb the checks are often inconsistent.

    How this differs from BOLA and IDOR

    People mix up broken function level authorization with BOLA, also called IDOR. Keeping them apart makes both easier to find.

    BOLA is about objects and data: can you read or change a record that is not yours. Broken function level authorization is about actions and functions: can you run an operation your role should never be allowed to run.

    Here is the split in plain terms:

    • BOLA / IDOR is object level. You are allowed to view invoices, but you change the id from your own invoice to GET /api/invoices/775 and read someone else’s. Same function, wrong object.
    • BFLA is function level. You are not allowed to promote users at all, but you call the promote function and it works. Different function, one your role should not touch.

    A quick test to tell them apart: ask whether the problem is whose data or which action. If swapping an id gets you another user’s record, that is BOLA. If calling an admin or privileged operation from a normal account just works, that is broken function level authorization. Many real APIs have both, and both belong under the same access control umbrella.

    Why the check goes missing

    This bug is rarely a typo. It comes from the way apps grow.

    • The UI is treated as the gate. If the admin button only renders for admins, developers assume the endpoint is safe. The endpoint has no idea what the UI showed.
    • Checks live in one place, not everywhere. A middleware guards /api/admin/*, then a new admin action ships under /api/users/ and slips past the rule.
    • New verbs get added later. The GET handler was reviewed months ago. The DELETE handler was added in a rush and nobody re checked the role logic.
    • Role checks are copied by hand. When every controller repeats its own if user.role == admin line, one forgotten line is all it takes.

    How to prevent it

    The fix is to make the allowed check the default, not something each route opts into.

    • Deny by default. Every endpoint should require an explicit role or permission to run. If a route declares nothing, it should reject the request, not accept it.
    • Check the caller’s role on the server, on every function. Do it inside the handler or a shared authorization layer, never in the frontend. The client cannot be trusted to hide anything.
    • Tie permissions to the operation, not the URL shape. Guard the promote action itself, so it stays guarded no matter what path or method reaches it.
    • Cover every verb. Apply the same check to GET, POST, PUT, PATCH, and DELETE on a resource, and confirm each one.
    • Test as a low privilege user. Log in as a normal member and try every admin and privileged route by hand. A 200 where you expected a 403 is the finding.

    Broken function level authorization survives because it depends on an assumption nobody wrote down: that only the right role would ever call a given function. An autonomous researcher that learns how an app is meant to work, then checks the caller’s role against every function it can reach, is built to catch exactly this kind of assumption based gap. A frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. You can read more on our about page.

    Frequently asked questions

    What is broken function level authorization?

    It is an access control flaw where an endpoint confirms you are logged in but never checks whether your role is allowed to run that function, so a normal user can call an admin or privileged action directly. It is API5 in the OWASP API Security Top 10.

    How is BFLA different from BOLA?

    BOLA is about data, reaching an object that belongs to another user. BFLA is about actions, running a function your role should not be allowed to run, such as deleting a record or promoting a user.

    What is the HTTP method version of BFLA?

    An app may protect GET on a path but forget to check DELETE or PUT on the same path. The read is guarded, the write is not, so an attacker changes the method and the privileged action goes through.

    How do you prevent broken function level authorization?

    Deny by default, check the caller’s role on every sensitive function on the server, and drive access from a central policy rather than from whether the interface showed a button.


    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.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

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

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.