If you only take one thing from this post, take this: scope every object lookup to the authenticated owner, and never let a request body write straight into a model.

// Order.findOne({ _id: id, userId: req.user.id }) — the owner check lives in the query, not an if-statement after it
// User.create(req.body) — this is the bug. Pick fields explicitly, every time.

Broken Object Level Authorization (BOLA) and mass assignment aren't exotic. BOLA is ranked #1 in the OWASP API Security Top 10, and mass assignment has caused some of the most well-known breaches in API history — yet both classes slide right past signature-based scanners, because neither one produces malformed traffic. Both attacks look like perfectly ordinary, well-formed API calls. There's no injection payload, no broken auth token, nothing a WAF pattern-matches against. The attacker just asks a question the server never thought to check: "wait, should this caller be allowed to touch that object?" or "should this caller be allowed to set that field?"

This post walks through both vulnerability classes in REST and GraphQL, shows the broken-vs-fixed code side by side, and ends with a defense playbook and test patterns you can drop into CI.


Why the Classic OWASP Top 10 Doesn't Cover This Well

The general OWASP Top 10 (the one most web developers know) buries access-control failures inside one broad bucket — A01:2021 Broken Access Control — broad enough that IDOR gets a footnote and mass assignment doesn't get a name at all. The OWASP API Security Top 10 was built specifically to close that gap, because APIs fail in ways web apps mostly don't: object-by-object, field-by-field, at machine speed.

List BOLA Mass Assignment
OWASP Top 10 (Web) 2021 Folded into A01, no dedicated name Not called out as its own class
OWASP API Security Top 10 2023 API1:2023 — ranked #1 Folded into API3:2023 Broken Object Property Level Authorization, alongside the old "Excessive Data Exposure" category
CWE CWE-639 (IDOR), CWE-284 CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes)

Worth knowing if you've read older OWASP API material: the 2019 edition of the API Top 10 had Mass Assignment as its own entry (API6). The 2023 revision merged it with Excessive Data Exposure into API3, on the reasoning that both are really the same underlying failure — the API doesn't control which properties of an object a caller can read or write — just approached from opposite directions (leaking a field on the way out vs. setting a field on the way in). It's a rename and a consolidation, not a retirement. The vulnerability itself hasn't gone anywhere.

Both flaws share the same root cause: the request is syntactically perfect. What's missing is a decision the server never made. Signature scanners, WAFs, and most SAST rulesets look for attacker input patterns — malformed JSON, SQL metacharacters, known exploit strings. BOLA and mass assignment hide in missing server-side decisions, which means the only reliable detector is a test that deliberately tampers with object IDs and field names and checks what comes back.

Real incidents, for scale

Incident Flaw What happened
Facebook Photos API (2018) Broken object/property access control A 12-day bug (Sep 13–25, 2018) let third-party apps that already had photo permission pull a broader set of photos than they were authorized for — including unposted draft photos Facebook temporarily stores. Facebook said it could have affected up to 6.8 million users across roughly 1,500 apps from 876 developers.
USPS Informed Visibility (2018) Broken object-level authorization A wildcard-friendly API let any logged-in usps.com user query account details — email, username, account number, street address, phone number — for other users, and in some cases modify them. Disclosed by KrebsOnSecurity; USPS said roughly 60 million accounts were exposed.
Twitter phone/email lookup API (2022) BOLA / enumeration An API meant to match a submitted phone number or email to a Twitter account ID had no rate limiting or ownership check tied to privacy settings. Reported via bug bounty in January 2022 and patched, but not before it was used to compile data on 5.4 million accounts, which later surfaced for sale.
GitHub (2012) Mass assignment A Rails mass-assignment bug let a researcher PUT an update to his own SSH public key record that included a user_id field pointing at a Rails core-team account, effectively granting himself commit access. Rails responded by pushing attr_accessible allowlisting into its defaults, which evolved into Strong Parameters in Rails 4.
Parler (2021) IDOR-adjacent broken access control Parler's post and video URLs used sequential, guessable IDs with no authentication and no rate limiting required to fetch them. Researchers walked the ID space and archived an estimated 70+ terabytes of posts and videos, including deleted and private content, before the service went offline.

None of these needed a zero-day. Every one of them needed a server to ask "may this caller touch this object or set this field?" — and didn't.


BOLA: Broken Object Level Authorization

Definition: the API correctly authenticates who is calling, but never checks which objects that caller is allowed to read or write. Any object reachable by ID becomes reachable by anyone who can guess, enumerate, or otherwise obtain that ID.

BOLA is IDOR with a newer name and a dedicated spot at #1 on the API Top 10. There's nothing technically clever about it — which is exactly why it survives. Authorization gets written once, at login, and nobody revisits it at the object layer.

REST shape (Express)

The classic mistake: auth middleware runs, confirms you're someone, and the handler trusts whatever ID shows up in the URL.

routes/orders.js — vulnerable version:

// VULNERABLE: authenticated, but no ownership check
app.get('/api/orders/:id', auth, async (req, res) => {
  const order = await Order.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});

// Attacker with any valid session: GET /api/orders/1041, /1042, /1043 ...

routes/orders.js — fixed version:

// FIXED: scope by owner in the data layer itself
app.get('/api/orders/:id', auth, async (req, res) => {
  const order = await Order.findOne({
    _id: req.params.id,
    userId: req.user.id,   // or tenantId for B2B multi-tenant APIs
  });
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});

A few design details that matter more than they look:

  • Return 404, not 403, for foreign objects. A 403 confirms the object exists — that's an enumeration oracle handed to the attacker for free. A 404 makes "wrong ID" and "nonexistent ID" indistinguishable from the outside.
  • UUIDv4 IDs slow down enumeration; they don't stop BOLA. Anyone who legitimately receives a share link already has the UUID. Treat opaque IDs as a speed bump, never as an authorization mechanism.
  • Write paths need the identical check. PATCH /api/orders/:id and DELETE /api/orders/:id fail the same way as the GET above — with higher blast radius.
  • Never trust an owner filter supplied by the client. A ?userId= in the query string or request body is attacker-controlled input. userId has to come from the authenticated session, full stop.

GraphQL shape (GraphQL-JS)

GraphQL collapses "where does this ID come from" into a single resolver surface, which tends to make BOLA easier to introduce rather than harder — authorization gets written once at the root query and quietly forgotten for every nested field underneath it.

Mistake 1 — root resolver with no scope:

resolvers/order.js — vulnerable:

const resolvers = {
  Query: {
    // VULNERABLE: any authenticated user resolves any order
    order: (_, { id }) => Order.findById(id),

    // Relay global IDs are not safer — same missing check
    node: (_, { id }, ctx) => resolveGlobalId(id), // returns the object, no ownership test
  },
};
# Attacker simply swaps IDs
query { order(id: "1042") { total customerEmail } }

resolvers/order.js — fixed:

order: (_, { id }, ctx) => Order.findOne({ _id: id, userId: ctx.user.id }),

Mistake 2 — authorization only at the root:

# "me" is checked. The nested "orders" field is not.
query {
  me { name
    orders { id total }   # this resolver must scope again — a parent check is not a child check
  }
}

Every resolver that touches a collection or a child object has to re-apply the owner or tenant filter on its own. Parent-level authorization does not inherit downward to children — GraphQL will happily let you forget that.

Mistake 3 — alias batching turns one request into a hundred probes:

query {
  a: order(id: "1001") { total }
  b: order(id: "1002") { total }
  c: order(id: "1003") { total }
  # ... hundreds more, in ONE HTTP request
}

A rate limiter keyed on request count barely notices this, because it's one request carrying hundreds of ID guesses as aliases. The actual fix is per-resolver authorization — the thing that was missing in the first place — with query depth and complexity limits as an additional brake on how much amplification a single request can achieve.


Mass Assignment: The Request Body Is Not a Model

Definition: the server maps client-supplied fields directly onto a persistent object or an update operation. Any field the attacker knows about — or correctly guesses — that the internal schema happens to use becomes writable from the outside.

ORM convenience makes this the default behavior in most stacks. An endpoint that "just saves the object" is usually saving every attribute the client remembered to include in the body, whether or not that attribute was ever meant to be client-settable.

REST shape (Express + Mongoose)

routes/users.js — vulnerable:

// VULNERABLE: whole body hits the model
app.post('/api/users/register', async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
});

// Attacker:
// POST /api/users/register
// { "name": "mallory", "email": "[email protected]", "role": "admin", "isVerified": true }

role and isVerified never appeared on your HTML registration form and aren't in your public OpenAPI spec — but the Mongoose schema has columns for them, so the model accepts whatever shows up with those names.

routes/users.js — fixed, explicit field selection:

const ALLOWED_FIELDS = ['name', 'email', 'avatarUrl'];

app.post('/api/users/register', async (req, res) => {
  const data = Object.fromEntries(
    Object.entries(req.body).filter(([key]) => ALLOWED_FIELDS.includes(key))
  );
  const user = await User.create({ ...data, role: 'user', isVerified: false }); // defaults set server-side, never from the client
  res.status(201).json(user);
});

PATCH endpoints are the higher-risk variant, because they're designed to accept partial bodies — a body-merge straight into the document exposes every privileged column the schema has:

routes/profile.js:

// VULNERABLE: PATCH /api/profile
await User.findByIdAndUpdate(req.user.id, req.body);          // role, balance, isVerified all ride along

// FIXED
await User.findByIdAndUpdate(req.user.id, pick(req.body, ALLOWED_FIELDS), { runValidators: true });

A quick framework-by-framework reality check:

Stack Default behavior Hardening
Express + Mongoose Model.create(req.body) accepts any schema path Manual allowlist. Mongoose strict: true (the default) only blocks fields not in the schema — it does nothing for privileged fields that are in the schema
NestJS Body → DTO binding ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
Rails (post-2013) Strong Parameters required params.require(:user).permit(:name, :email) — the exact lesson GitHub's 2012 incident paid for
Django Model(**request.data) is risky Explicit fields on a ModelSerializer

That Mongoose strict mode note is worth sitting with for a second: it strips fields that aren't part of the schema at all. role usually is part of the schema, because the application needs it somewhere — so strict mode changes nothing for the field that actually matters. Only an allowlist closes the gap.

GraphQL shape (GraphQL-JS)

GraphQL schemas feel inherently safer, because a field that doesn't exist on the input type gets rejected at validation, before your resolver even runs. The flaw reappears the moment a privileged field joins that input type — and in practice it does, because one UserInput type tends to get reused for reads, writes, and admin operations, "because the type already has everything."

schema/user.graphql — vulnerable:

# VULNERABLE: one input type doing reads, writes, and admin all at once
input UserInput {
  id: ID
  name: String
  email: String
  role: String         # privileged — present because "the type has everything"
  isVerified: Boolean  # privileged
}

type Mutation {
  updateProfile(input: UserInput!): User
}

resolvers/user.js — vulnerable:

Mutation: {
  // VULNERABLE: input object spread straight into the update
  updateProfile: (_, { input }, ctx) =>
    User.findByIdAndUpdate(ctx.user.id, input, { new: true }),
}
# Attacker mutation — the schema happily validates this
mutation { updateProfile(input: { role: "admin", isVerified: true }) { id role } }

Fixing this properly means fixing it at both layers — a narrower schema, and a resolver that still doesn't trust it:

schema/user.graphql — fixed:

# Only client-settable fields exist on this input type at all
input ProfileUpdateInput {
  name: String
  email: String
  avatarUrl: String
}

type Mutation {
  updateProfile(input: ProfileUpdateInput!): User
}

resolvers/user.js — fixed:

Mutation: {
  updateProfile: (_, { input }, ctx) => {
    const data = pick(input, ['name', 'email', 'avatarUrl']); // schema ≠ full trust
    return User.findByIdAndUpdate(ctx.user.id, data, { new: true, runValidators: true });
  },
}

Why the resolver-level pick earns its place even when the schema already looks tight:

  • Resolvers frequently forward the whole input object downstream to services, other ORMs, or internal HTTP clients — code you didn't write may re-attach fields you never intended to expose.
  • Input types get reused across mutations as a codebase evolves; a later refactor adds a field "just for the admin variant" and nobody revisits every consumer of that type.
  • Nested input objects — where, filter, set — smuggle the identical pattern one level deeper, where it's easier to miss in review.

The schema is documentation and first-pass validation. The allowlist in the resolver is the actual control.


Defense Playbook

Layer BOLA Mass assignment
Route / resolver Scope every query by userId / tenantId pulled from the session — never from params pick(body, ALLOWED) before any model call, no exceptions
Framework Central authorizeOwnership() middleware, or an authz directive (@auth) on fields ValidationPipe({ whitelist: true }), Strong Parameters, serializer fields
Model — Privileged columns: immutable from client input or write-only for trusted service accounts; defaults assigned server-side
GraphQL schema Per-field auth directives; depth and complexity limits; introspection off in production One narrow input type per mutation; never reuse a read model as a write input
IDs UUIDv4 — a speed bump, not a control —
Responses 404 for foreign objects, to avoid an existence oracle Strip privileged fields from responses too, not just from inputs
Tests Fuzz random and neighbor IDs against every endpoint; expect 404 Inject role, is_admin, balance into every write endpoint; assert the value never moved

Here's both flaws covered in one test file:

tests/api-security.test.js:

test('neighbor order IDs 404 for non-owners', async () => {
  const res = await request(app)
    .get(`/api/orders/${foreignOrderId}`)
    .set('Authorization', `Bearer ${token}`);
  expect(res.status).toBe(404);
});

test('injected privileged fields are dropped', async () => {
  await request(app)
    .post('/api/users/register')
    .send({ name: 'x', email: '[email protected]', role: 'admin', isVerified: true });
  const user = await User.findOne({ email: '[email protected]' });
  expect(user.role).toBe('user');
  expect(user.isVerified).toBe(false);
});

Run the fuzz tests in CI, not just locally before a release. An authorization regression ships silently otherwise — every endpoint still returns a 200 for some legitimate user, which is all most test suites bother to assert.


Checklist

  • [ ] Every object read/write scoped by a session-derived owner or tenant ID — on the REST route and on every GraphQL resolver, not just the root
  • [ ] Parent-level GraphQL authorization never stands in for child-resolver authorization
  • [ ] 404 (not 403) for objects the caller doesn't own
  • [ ] No Model.create(req.body) / update(req.body) anywhere in the codebase — allowlist at the edge, always
  • [ ] Privileged defaults (role, isVerified, balance) assigned server-side only, never accepted from client input
  • [ ] GraphQL: narrow, per-mutation input types; privileged fields absent from both the schema and the resolver's pick
  • [ ] Query depth/complexity limits enabled; introspection disabled in production
  • [ ] CI tests fuzz foreign object IDs and inject privileged fields on every write endpoint
  • [ ] Public-facing IDs are UUIDv4 (a speed bump), and 404 patterns are logged for enumeration detection

Bottom Line

Authentication proves who's calling. Authorization decides what they're allowed to touch — and that decision has to get re-made on every object lookup and every field write, not once at the login screen and never again. BOLA skips the object question. Mass assignment skips the field question. Both send traffic that looks completely ordinary to everything except a test specifically built to tamper with IDs and inject fields. The classic Web Top 10 won't remind you this exists. Your fuzz suite will — if you write it.