API security testing: object authz, extra fields, rate limits

Numbered coat hooks with an extra coral slip, house style.

OWASP’s API Security Top 10 2023 is still the current API list on 22 August 2026. There is no 2025 API edition. I opened the project page. API1 is still object-level authorization. A weekly scan that never swaps the session will score GET /tickets/:id green, because the response is a valid 200 for the user who owns the row.

You already have two fixtures. You already have supertest. You already know Alice’s id. The job is to assert the handler refused the other user, the extra field, the burst, and the unexpected key.

Pair the assertions with the IDOR guide for the WHERE clause, the injection guide for the interpreter, and JWT in Express if the credential is a bearer. The secure coding checklist is the rest of the floor.

CI tests, not a scanner quote

API1:2023 is Broken Object Level Authorization, the same bug this site calls IDOR. API3:2023 merged mass assignment and extra response fields into property-level authorization. API4:2023 is unrestricted resource consumption. Those three names are the 2023 list, still current. I am not waiting for an API 2025 list that does not exist.

A vendor scan that fuzzes paths without two logged-in users will miss API1. A contract test that only checks the happy JSON will miss API3. A load test that never hits /login will miss the cheap lockout. This page is the four assertions you own. It is not a product comparison and it is not a statement of work.

A 200 for Alice is not a test of Bob. The replay is the test.
HAPPY    Alice GET /tickets/aliceTicketId
         Cookie: aliceSid
         handler returns 200 and the row
         contract test is green

REPLAY   Bob GET /tickets/aliceTicketId
         Cookie: bobSid
         same path, same id, other session
         expect 404 from the scoped query

FIX      WHERE id = $1 AND org_id = $2
         two fixtures in CI
         a scanner that never swapped the
         cookie cannot see this miss

Authz on every object

The handler that only asks for the id is the miss. findById(req.params.id) then res.json(row) is a passing contract test and a failing authz test. The tenant belongs in the same WHERE as the id. Load-then-compare is how updates skip the check. The IDOR page is the control. This page is the proof.

Create two users in the test database. Give Alice one ticket. Give Bob a different org. Identifiers stay aliceSid, bobSid, and aliceTicketId for the rest of the snippets.

const request = require("supertest");
const { app } = require("../app");

function authed(sid) {
  return request(app).set("Cookie", `__Host-session=${sid}`);
}

const ticketPath = `/tickets/${aliceTicketId}`;

test("bob cannot read aliceTicketId", async () => {
  const res = await authed(bobSid).get(ticketPath);
  expect(res.status).toBe(404);
  expect(res.body.id).toBeUndefined();
});

Expect 404, not 403. A 403 on a known id is an existence oracle. The scoped query returns zero rows for Bob, the same as a missing id. Repeat the replay on PATCH, DELETE, and any export or PDF route that takes the same id. The route that was copied last week is the one that forgot the predicate.

A UUID does not change the assertion. Sjoerd’s line above is the whole argument. Guessability is a rate-limit problem. Authorization is a WHERE-clause problem. Run both.

Mass assignment on PATCH

API3 on the write path is a body that sets a field the caller does not own. role, ownerId, orgId, isAdmin, emailVerified. The handler that spreads req.body into prisma.ticket.update will honor them. The happy-path test sends memo and never notices.

test("patch cannot set ownerId or role", async () => {
  const res = await authed(aliceSid)
    .patch(ticketPath)
    .send({ memo: "nudge", ownerId: "bob", role: "admin" });
  expect(res.status).toBe(400);

  const again = await authed(aliceSid).get(ticketPath);
  expect(again.body.ownerId).not.toBe("bob");
  expect(again.body.role).toBeUndefined();
});

Alice is the caller on purpose. Mass assignment is not “the other user.” It is the same user writing a column the schema must not accept. After the 400, read the row back. A strip-unknown parser can return 200 and quietly drop ownerId. That is safer than a spread, and it hides the probe. Prefer 400 so you can alert. The next section is that parser.

Reject unknown body keys

zod’s default z.object strips unknown keys. That is why a probe for role can look like a successful memo update. .strict() turns the extra key into a throw you map to 400. Apply it on nested objects too. The modifier does not recurse.

const { z } = require("zod");

const TicketPatch = z.object({
  memo: z.string().max(500).optional(),
  status: z.enum(["open", "done"]).optional(),
}).strict();

function readPatch(req, res, next) {
  const parsed = TicketPatch.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "invalid_patch" });
    return;
  }
  req.patch = parsed.data;
  next();
}

TicketPatch is the only body the update may see. ownerId and role are not in the object, so they cannot enter data. The create path is the same rule: orgId comes from req.actor, never from the JSON. Probe with a surprise key and expect 400. Probe with a string where a number belongs and expect 400. Neither is an injection cookbook. If a leftover string still reaches SQL, stop here and open the injection guide.

On the way out, serialize an allowlist. res.json(row) on the ORM model is how passwordHash and internalNotes leave the building. API3 is both directions. Assert those keys are missing on the GET. Assert they cannot be written on the PATCH.

test("ticket json omits internalNotes", async () => {
  const read = await authed(aliceSid).get(ticketPath);
  expect(read.status).toEqual(200);
  expect(read.body).not.toHaveProperty("internalNotes");
  expect(read.body).not.toHaveProperty("passwordHash");
});

Rate-limit the routes that hurt

express-rate-limit 8.6.2 published on 4 August 2026. I opened the npm page. The option is now limit, not max. standardHeaders is "draft-8". The in-process store is the default. Two Node processes do not share it. Redis is the store this page means in production. passOnStoreError defaults to false, which is fail closed if Redis is down. Leave that default.

Do not put one global 100-per-15-minutes bucket on the whole app and call login done. Login, password reset, and one-time codes need a tighter limiter and a key that is not only the IP. Shared egress NATs will lock a campus if you key only on IP. Key on IP plus the normalized email for those routes. Key on req.actor.userId for authenticated exports.

const { rateLimit } = require("express-rate-limit");

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  standardHeaders: "draft-8",
  legacyHeaders: false,
  passOnStoreError: false,
  keyGenerator: (req) => {
    const email = String(req.body.email || "").toLowerCase();
    return `${req.ip}:${email}`;
  },
});

app.post("/login", loginLimiter, loginHandler);

The test is a burst you run against your own app:

test("eleventh login is 429", async () => {
  for (let i = 0; i < 10; i += 1) {
    await request(app)
      .post("/login")
      .send({ email: "alice@your-app.example", password: "wrong" });
  }
  const last = await request(app)
    .post("/login")
    .send({ email: "alice@your-app.example", password: "wrong" });
  expect(last.status).toEqual(429);
});

Count must match limit. If you share the in-process store across tests, reset it in beforeEach or the eleventh call is not the eleventh. A missing 429 means the limiter is not mounted, the key ignored the email, or another test emptied the window.

The replay is the same idea with a different header. Cookie apps send Cookie: __Host-session=.... Bearer apps send Authorization: Bearer .... Swap the credential. Keep the path. Expect 404. If you issue a JWT, the test still belongs here. Verification of alg, aud, iss, and exp belongs on the JWT page. A signed blob that names Alice does not authorize Alice to read Bob’s ticket. The object check still runs after jose.jwtVerify.

test("bob bearer cannot read aliceTicketId", async () => {
  const denied = await request(app)
    .get(ticketPath)
    .set("Authorization", `Bearer ${bobAccess}`);
  expect(denied.status).toEqual(404);
});

Do not invent a CSRF token to feel busy on a bearer-only API. The browser is not attaching a cookie. CORS is the request-origin job. Object authz is still the id job. If the app is a cookie session, the CSRF page is a different ticket. This page does not replace it.

Four tests you commit

Name them so a missing file is obvious. Identifiers stay aliceSid, bobSid, aliceTicketId, ticketPath, TicketPatch, and loginLimiter.

TestRequestExpect
Object authzBob GET /tickets/aliceTicketId404, no row fields
Mass assignmentAlice PATCH with ownerId and role400, row unchanged
Extra keysAlice PATCH with a key not in TicketPatch400 from .strict()
Rate limitEleven POSTs to /login as Alice429 on the eleventh

Grep the routers for the two shapes that skip the first test:

rg -n "findById\\(|findUnique\\(|\\.update\\(\\{\\s*where:\\s*\\{\\s*id" --glob '!node_modules'

A where: { id } without the tenant is a review. Add the replay before you rename the function. Headers, OpenAPI badges, and a purchased scan do not replace the four rows. If a string still reaches SQL after TicketPatch, that is injection, not an API-testing gap.

Questions we keep getting

Is a UUID enough if I also rate-limit?

No. Rate limits slow a guess. They do not stop a forwarded link, a log line, or a support export that already has the id. Replay Bob on Alice’s id. Expect 404. That is the object test.

Should I fail the build if extra fields are stripped?

Yes if you use .strict(). Stripping is quieter and easier to miss in production. 400 is a signal you can page on. Nested objects need their own .strict(). The parent modifier will not recurse.

Do I need a paid API scanner to ship?

No. The four tests above are yours. A scanner can find a forgotten route. It cannot replace two fixtures and a scoped WHERE. Do not delay a replay test because a vendor demo is on the calendar.

Gilad David Maayan / About Author

Gilad David Maayan is a technology writer who has worked with over 150 technology companies including SAP, Imperva, Samsung NEXT, NetApp and Ixia, producing technical and thought leadership content that elucidates technical solutions for developers and IT leadership.

LinkedIn