Your app can pass a scanner and still return one user’s private record to another user. For a junior developer starting from zero, I would build a repeatable cross-account test before adding a large security toolchain: it checks a specific failure that automated scans often lack the identities and data to expose. A small, trustworthy gate is a better starting point than a dashboard full of untriaged findings.
Start with one ownership rule, not a scanner configuration
Pick an endpoint that returns data owned by a user, such as GET /api/notes/:id. Write down its contract before writing a test: the owner receives HTTP 200, an authenticated non-owner receives HTTP 404, and an unauthenticated caller receives HTTP 401. Returning 404 for the non-owner is a product choice, not a universal security requirement; it fits this example because it avoids confirming that someone else’s note exists. If your API deliberately uses HTTP 403, test for 403 instead.
Use OWASP API Security Top 10’s API1:2023 Broken Object Level Authorization as the name for the failure you are testing. The weakness also maps to CWE-639. These references are useful because they keep the task specific: the question is whether the server checks ownership for the requested object, not whether the client hides another user’s note in its interface.
I would not begin by installing every available scanner, because a scan without seeded users, a known private object, and a defined expected response cannot prove this ownership rule. The buy-or-build question in Cheap vs Proper App Security Testing What a CTO Should Buy or Build is worth revisiting after the team has a concrete gate that a tool or service must improve.
For this walkthrough, assume an existing Express 5 API with token-based authentication and a notes endpoint. Node.js 22 is a practical baseline here because it includes the built-in node:test runner; use your repository’s supported Node version if it differs. You do not need to replace your app’s test framework. The essential requirement is an environment in which a test can create two accounts and call the real route through the real authentication middleware.
Two accounts turn an abstract risk into a testable request
Create Alice and Bob in an isolated test database, authenticate both, and create a note owned by Alice. Keep the note ID and both tokens in test variables. This fixture must use the same ownership field and authorization path as production; otherwise, a passing test could validate a mock while the actual route remains vulnerable. PostgreSQL in a disposable container is appropriate if production uses PostgreSQL, because database-level filters and query behavior can affect the result.
First send Alice’s token with Alice’s note ID and require 200. Then send Bob’s token with the same ID and require 404. That first request matters: without it, a misspelled route, missing seed record, or broken server could produce a misleading 404 for Bob. Also try the request without a token and require 401, because rejecting an unauthenticated caller is a separate behavior from rejecting the wrong owner.
The following Bash check runs against a started test server with seeded data. Set BASE_URL, ALICE_TOKEN, BOB_TOKEN, and ALICE_NOTE_ID in the shell or CI environment, then save the script as scripts/check-note-ownership.sh and run it with Bash. The 200 and 404 responses are expected results for this example’s API contract, not claimed measurements of your app.
#!/usr/bin/env bash
set -euo pipefail
: "${BASE_URL:?}"
: "${ALICE_TOKEN:?}"
: "${BOB_TOKEN:?}"
: "${ALICE_NOTE_ID:?}"
url="${BASE_URL}/api/notes/${ALICE_NOTE_ID}"
owner=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${ALICE_TOKEN}" "$url")
other=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${BOB_TOKEN}" "$url")
test "$owner" = 200 || { echo "Owner request returned $owner"; exit 1; }
test "$other" = 404 || { echo "Other-user request returned $other"; exit 1; }
echo "Ownership test passed"
curl makes this useful as a first executable check, but move the case into node:test with Supertest once the fixture is stable. Supertest can call the exported Express app without managing a separate listening port, which removes one common source of CI failures. Keep the request assertions the same. Do not print the tokens while debugging: a CI log is easier to share than a credential is to revoke.
Before declaring success, make the test fail on purpose in a local branch. Temporarily remove the owner condition from the note query, run the check, and confirm that Bob receives 200 and the script exits unsuccessfully. Restore the condition immediately. This controlled failure demonstrates that the test observes the authorization rule rather than merely executing a route.
A small CI gate should fail clearly before it grows
Put the seeded ownership test in a pull-request workflow using actions/checkout@v4 and actions/setup-node@v4, followed by npm ci, the database setup, and the test command. Pin the Node major version to 22 if that matches local development. A committed package-lock.json makes npm ci reproduce dependency selection more predictably than a fresh install, so a failing pull request is easier to investigate.
Give the test database a fresh schema or container per run. Reusing a shared database makes failures depend on old notes and users, which defeats the point of the fixture. Set a 10-minute job timeout as an initial value to tune, not a performance fact: it prevents a stuck setup from occupying a runner indefinitely while leaving room for a database to start. Once the workflow is reliable, record its measured duration across 20 runs before tightening that limit.
Add npm audit –audit-level=high as a separate CI step, because a dependency advisory and an ownership regression require different fixes. Run Gitleaks v8 against committed content to catch likely secrets, and review its findings before blocking every pull request because test fixtures can contain harmless token-shaped strings. Semgrep can flag selected code patterns, but it should complement the request test: a static rule cannot establish that Bob is denied access to Alice’s seeded record at runtime.
Keep the reports separate enough that a developer can answer, “Which command failed, on which request or file, and what changed?” I use App Testing Security and QA Best Practices as a prompt to consider QA alongside security checks, but I would not collapse a broken unit test, a dependency advisory, and a scanner warning into one unexplained “security failed” status. Clear failure messages make the gate more likely to be repaired rather than bypassed.
Choose ZAP’s baseline scan before an active scan—unless the target is disposable
Once the ownership test runs in CI, add an HTTP-level scan against a local or staging instance. Two named options are the OWASP ZAP baseline scan and a ZAP authenticated active scan. Baseline wins first when you need a low-disruption check for discoverable pages, headers, and passive findings; its cost is limited coverage, especially for routes that require authentication or specific object IDs. An authenticated active scan wins when you can supply test identities and a disposable target; its cost is more setup, more triage, and requests that may modify data.
ZAP’s published baseline default includes a 1-minute spider pass. Treat that as the tool’s documented starting behavior, not evidence that your entire API was visited. Review the captured URLs: if /api/notes/:id never appears, a clean report says nothing about that route. An OpenAPI 3.1 document can help define endpoints for testing, but it still does not supply Alice’s note, Bob’s session, or the ownership assertion.
Do not point an active scan at production merely because the baseline was quiet. Active probes can submit unexpected values and alter state, so use a disposable environment with seeded records and an explicit cleanup procedure. If your team later buys a managed testing service, ask it to demonstrate the Alice/Bob case and show the resulting requests. A report with many findings is less persuasive for this risk than one reproducible request that crosses an account boundary.
The setup is working when a bad ownership change blocks a pull request
Make the ownership test a required pull-request check only after you have observed both its passing run and its deliberate failure. Then add one regression case for each new object-owning endpoint, because a note test will not protect an attachment route whose query omits the owner. Track the number of protected routes against the routes that return user-owned objects; that ratio is a coverage measure you can actually improve, unlike a vague claim that the app is “secure.”
Open your app’s routes today and choose one endpoint that returns a user-owned object. Seed two accounts, record the owner’s successful request, and run the non-owner request against the same ID. If the second request succeeds, fix the server-side query before adding another tool. If it fails as intended, make that exact check fail on a deliberately broken branch, then put it in CI.



