Key points
- Give two test accounts the same role and separate records, e.g. one order each.
- Send each of account A's page, API, and file requests again with account B's session.
- Check the response body and account A's stored data, not only the status code.
How do you run a two-account access test?
To run a two-account access test, create two test users, A and B, each with private records. Sign in as B, request each of A's pages, API routes, and files by URL, and try to change them. Each request should fail authorization, and A's data should stay unchanged. Repeat with the roles swapped.
A two-account test is a behavior check that confirms that neither user can see or change the other's data. It looks for broken object level authorization (BOLA), which leads the API Security Top 10 of the Open Worldwide Application Security Project (OWASP). A server with BOLA acts on any record that an ID in the request names, without checking the user's right to it. The OWASP Authorization Cheat Sheet calls this a form of insecure direct object reference (IDOR).
The two-account test is a black-box check that sends only what a user can send. The same test works for a command-line application that calls the server with a user's token.
What do you need before you start?
You need these parts:
- A test copy of the app. It runs the build under test with seeded data and no real customers.
- Two accounts with the same role. A and B are both customers with records of their own. In an app that several organizations share, put them in different organizations, also called tenants.
- A way to send requests as each account. A browser test framework, e.g. Playwright, can load a saved session for each account.
- An agreed refusal. The team picks one correct refusal, usually
403 Forbiddenor404 Not Found. A404also hides whether the record exists.
How do you run a two-account access test step by step?
Here is an illustrative example. Acme Co. sells furniture online. A developer asks a coding agent to "Let customers edit their delivery address during checkout."
The agent adds API routes that read an order and change its address. Its tests pass with one customer. The order page and PDF invoices already exist.
1. Create two accounts with their own data
A seed script creates 2 customers with the customer role. Customer A owns order A-1042, with 12 Elm Street as its delivery address and an invoice. Customer B owns order A-1043.
2. List the requests that touch account A's data
Signed in as A, the developer uses each feature with the browser's developer tools open and lists the requests:
| Data | Request as customer A | Correct result for B |
|---|---|---|
| Order page | GET /orders/A-1042 | 404 |
| Order data | GET /api/orders/A-1042 | 404 |
| Order list | GET /api/orders | No order A-1042 |
| Address change | PATCH /api/orders/A-1042/address | 404, address unchanged |
| Invoice | GET /api/orders/A-1042/invoice | 404 |
| Invoice file | The file URL the invoice returns | Refused to B and when signed out |
3. Save a session for each account
Before the other tests run, a setup test signs in as each customer and saves the session's cookies and local storage to a file:
// tests/auth.setup.ts. In playwright.config.ts, a "setup" project matches
// *.setup.ts, and the test project lists it in dependencies, so it runs first.
import { test as setup } from "@playwright/test";
for (const who of ["a", "b"]) {
setup(`sign in as customer ${who}`, async ({ page }) => {
await page.goto("https://shop.example.com/login");
await page.getByLabel("Email").fill(`customer-${who}@example.com`);
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD ?? "");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("**/account");
await page.context().storageState({ path: `playwright/.auth/${who}.json` });
});
}
Playwright's docs warn that anyone with these files could act as the test accounts, so playwright/.auth goes in .gitignore.
4. Replay account A's requests as account B
The first test loads B's saved session and sends each request as an API test with B's cookies. expect.soft records each failure without stopping the loop:
import { test, expect } from "@playwright/test";
const shop = "https://shop.example.com";
const paths = ["/orders/A-1042", "/api/orders/A-1042",
"/api/orders/A-1042/invoice"];
test("customer B cannot read customer A's order", async ({ browser }) => {
const b = await browser.newContext({ storageState: "playwright/.auth/b.json" });
for (const path of paths) {
const res = await b.request.get(shop + path, { maxRedirects: 0 });
expect.soft(res.status(), path).toBe(404);
}
const list = await (await b.request.get(shop + "/api/orders")).text();
expect.soft(list, "B's own order").toContain("A-1043");
expect.soft(list, "order list").not.toContain("A-1042");
});
A second test sends B's address change, then reads the order as A, because a status does not show what the server stored:
test("customer B cannot change customer A's order", async ({ browser }) => {
const a = await browser.newContext({ storageState: "playwright/.auth/a.json" });
const b = await browser.newContext({ storageState: "playwright/.auth/b.json" });
const url = shop + "/api/orders/A-1042";
const edit = await b.request.patch(url + "/address", {
data: { address: "1 Oak Lane" },
});
expect.soft(edit.status(), "address change").toBe(404);
const order = await (await a.request.get(url)).json();
expect(order.address).toBe("12 Elm Street");
});
5. Read the failures
Run npx playwright test. Both tests fail with these errors, shortened:
Error: /api/orders/A-1042
Expected: 404
Received: 200
Error: /api/orders/A-1042/invoice
Expected: 404
Received: 302
Error: address change
Expected: 404
Received: 200
Error: expect(received).toBe(expected)
Expected: "12 Elm Street"
Received: "1 Oak Lane"
The order page answered 404, because its server code checks the owner. The agent's routes check only that someone is signed in, so B read A's order and changed its address. The invoice route redirected to https://files.example.com/invoices/A-1042.pdf, and that file opened in a private window.
6. Fix the check on the server and keep the test
The agent moves the owner check into the shared code that loads an order, and the invoice route sends the file from private storage after that check. Both tests pass, and A's own requests still return 200.
Acme runs the setup test and both access tests in continuous integration (CI) on each pull request, against a freshly seeded test copy. A leak fails the check.
This example is simplified. A real app has more private data, e.g. saved payment methods.
What changes when a coding agent writes the code?
When a coding agent checks its change through browser automation, it usually signs in with one test login. Each request it sends is one the app should allow. The prompt rarely says which customer may read which order, and the agent's tests come from the same prompt. Access bugs are one group of common bugs in AI-generated code.
TechCrunch reported in September 2026 that security firm UpGuard found about 16,000 Supabase databases exposing personal data through basic misconfiguration. Some apps let the browser query a hosted database directly, so the database's row-level security rules are the only check. For such an app, add each database request the browser sends to the step 2 list, and replay it with B's token in the Authorization header.
An agent asked to make the test pass can change the expected status instead of the server. Write the test before the agent starts, and keep it in a file the agent cannot edit, e.g. through its permission settings. Add each route the agent creates to the test's list.
What are common mistakes?
These mistakes let an access bug pass the test:
- Testing only the pages. A page can hide another customer's order while the API route behind it returns the order.
- Reading only the status code. A page built in the browser answers 200 whether or not A's order shows. Open the page as B in a headless browser and check that A's order does not show.
- Changing only the ID in the path. IDs also travel in query strings, request bodies, and headers, e.g. an organization ID header.
- Trusting IDs that are hard to guess. Random IDs still leak through shared links and logs, and OWASP calls them generally not enough by themselves.
- Forgetting stored files. Invoices and uploads often sit in cloud storage, outside the app's checks. Request each file URL as B and signed out.
How do you check that it worked?
A two-account test can pass for the wrong reason, e.g. a mistyped URL that returns 404 for everyone. These checks rule that out:
- Run the same requests as account A. Each one returns 200 with A's data, so the URLs are right.
- Remove a check on purpose. On a branch, delete one route's owner check, and confirm that the test fails there.
- Run the requests signed out. Each one gets
401or a redirect to the login page.
What does the test find and miss?
A two-account test finds access bugs only on the requests it sends:
| Access bug | Does the test find it? |
|---|---|
| An API route with no owner check | Yes, on the listed URLs |
| An owner check in the page only | Yes, when the test calls the API too |
| Files at public storage URLs | Yes, when the file URLs are listed |
| A customer who reaches an admin page | No, that needs accounts with two roles |
A login check that fails open lets requests through only when the check itself errors, so a test run with valid sessions passes. A user journey test with one user misses each bug in the table.
The test is one narrow form of adversarial testing. Flaws that normal requests do not trigger, e.g. injection, need the separate review that AI-generated code security describes. Admin pages and password resets need the other access checks before launch. No findings is not the same as complete coverage.
FAQs
What is an IDOR vulnerability?
An IDOR vulnerability, short for insecure direct object reference, lets a user reach another user's record by changing an ID in a request. The server acts on the ID without checking the user's right to that record. OWASP's list of API security risks covers this flaw as broken object level authorization.
Can a two-account test run automatically in CI?
A two-account test can run automatically in CI when a setup test first signs in as both accounts. The tests then replay A's requests as B on each pull request, against a freshly seeded test copy, and a leak fails the check.
Does a two-account test replace a security review?
A two-account test does not replace a security review. The test checks one rule, whether one user can reach another user's data, on the requests it sends. A security review also looks for flaws that normal requests do not trigger, e.g. injection.
How do you test access between organizations that share one app?
You test access between organizations, or tenants, that share one app the same way, with account A in one organization and account B in another. Replay A's requests as B, and also change any organization ID sent in a header or body.