Get the user from your authentication inside each route handler and server action, never from the request, and put one scope in every invoice query: tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin). Then detail, list and export return only readable rows. Answer 404 otherwise, and refuse an export when any requested ID is out of scope.
- List every route handler, server action and server component that reads an invoice: detail, list, search, export, download. Note where each one gets the invoice IDs and the user.
- Call your authentication at the top of each one (here currentUser(request); in a server action or server component, your session library's auth() or a helper that reads cookies()) and get { id, tenantId, role } built from server-side data. Delete any code that takes a user ID, tenant ID or role from searchParams, the body, form fields or a header.
- Keep data access in one module (a data access layer) whose functions take the user as a parameter and add tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin) to the WHERE clause, with the values bound from that user.
- Return 404 when the scoped query finds nothing, so a missing invoice and someone else's look the same.
- For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
- Add regression tests that call each handler as a colleague, as a member of another tenant and as a billing admin of another tenant, and replay invoice IDs against the detail, list and export routes.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function getInvoice(db: DatabaseSync, user: User, id: number) {
return db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = ?`)
.get(id) as Invoice | undefined;
}
export function listInvoices(db: DatabaseSync, user: User) {
return db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE tenant_id = ?
ORDER BY id`)
.all(user.tenantId) as Invoice[];
}
export function exportInvoices(db: DatabaseSync, user: User, ids: number[]) {
return db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (SELECT value FROM json_each(?))
ORDER BY id`)
.all(JSON.stringify(ids)) as Invoice[];
}With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).
The fix
// Members read their own invoices; billing admins read every invoice in their own tenant. The
// tenant test sits outside the parentheses, so it binds admins too:
// tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
function scope(user: User) {
// node:sqlite binds booleans only from Node.js 26.8.0, so pass 1 or 0.
return { tenant: user.tenantId, user: user.id, isBillingAdmin: user.role === 'billing_admin' ? 1 : 0 };
}
export function getInvoice(db: DatabaseSync, user: User, id: number) {
return db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = $id AND tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)`)
.get({ id, ...scope(user) }) as Invoice | undefined;
}
export function listInvoices(db: DatabaseSync, user: User) {
return db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
ORDER BY id`)
.all(scope(user)) as Invoice[];
}
export function exportInvoices(db: DatabaseSync, user: User, ids: number[]) {
return db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (SELECT value FROM json_each($ids))
AND tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
ORDER BY id`)
.all({ ids: JSON.stringify(ids), ...scope(user) }) as Invoice[];
}With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
function canRead(user: User, invoice: Invoice) {
return invoice.owner_id === user.id || user.role === 'billing_admin';
}
export function getInvoice(db: DatabaseSync, user: User, id: number) {
const invoice = db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = ?`)
.get(id) as Invoice | undefined;
return invoice && canRead(user, invoice) ? invoice : undefined;
}
export function listInvoices(db: DatabaseSync, user: User) {
return (db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
ORDER BY id`)
.all() as Invoice[])
.filter((invoice) => canRead(user, invoice));
}
export function exportInvoices(db: DatabaseSync, user: User, ids: number[]) {
return (db.prepare(`
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (SELECT value FROM json_each(?))
ORDER BY id`)
.all(JSON.stringify(ids)) as Invoice[])
.filter((invoice) => canRead(user, invoice));
}With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).
Route handler: the user comes from authentication, not the request
// GET /invoices/{id}. currentUser() is your session or token check: it builds { id, tenantId,
// role } from server-side data or returns undefined. The handler passes that user to the invoice
// module and never reads a user, tenant or role from the request.
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await currentUser(request);
if (user === undefined) return signInFirst();
const { id } = await params;
const invoice = /^\d{1,9}$/.test(id) ? currentImpl().getInvoice(getDb(), user, Number(id)) : undefined;
// Missing and not yours get the same answer, so IDs cannot be probed.
if (invoice === undefined) return notFound();
return Response.json(invoice);
}The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// Denied looks exactly like missing: 404 and none of the invoice's data.
async function expectHidden(user: string, path: string, number: string) {
const res = await get(user, path);
expect(res.status, `expected 404, got ${res.status} ${res.body}`).toBe(404);
expect(res.body, `response leaked ${number}`).not.toContain(number);
}
test("a member cannot read a colleague's invoice in the same tenant", async () => {
await expectHidden('alice', '/invoices/502', 'INV-502');
});
test('a member of another tenant cannot read the invoice', async () => {
await expectHidden('erin', '/invoices/501', 'INV-501');
});
test('a billing admin cannot read an invoice in another tenant', async () => {
await expectHidden('cara', '/invoices/601', 'INV-601');
});
test("a member's list leaves out colleagues' invoices", async () => {
expect(await listedIds('alice')).toEqual([501, 503]);
});
test("a billing admin's list stays inside their tenant", async () => {
expect(await listedIds('cara')).toEqual([501, 502, 503]);
});
test("an export that includes a colleague's invoice is refused whole", async () => {
await expectHidden('alice', '/invoices/export?ids=501,502', 'INV-502');
});
test("a billing admin's export that reaches into another tenant is refused whole", async () => {
await expectHidden('cara', '/invoices/export?ids=502,601', 'INV-601');
});
});- A check in proxy.ts (middleware.ts before Next.js 16) is not authorization. It only sees the path, a matcher change can drop a route from it, and CVE-2025-29927 let a forged x-middleware-subrequest header skip middleware entirely before 15.2.3, 14.2.25, 13.5.9 and 12.3.5. Next.js's own docs say to check authentication and authorization inside each server function.
- Server actions are public POST endpoints. An invoice ID passed as an action argument or a hidden form field is attacker-controlled, exactly like a route parameter, so the action needs the same scoped query.
- invoice.owner_id === user.id || user.role === 'billing_admin' lets a billing admin of any tenant read every tenant's invoices. That is this card's wrong implementation, and every test inside one tenant passes against it.
- Mixing && and || without parentheses does the same: sameTenant && isOwner || isAdmin parses as (sameTenant && isOwner) || isAdmin. Parenthesize the role branch in TypeScript and in SQL, where AND also binds tighter than OR.
- Filtering in TypeScript after an unscoped SELECT loads other tenants' rows into memory and breaks LIMIT, OFFSET and counts. Put the scope in the WHERE clause.
- A cache shared between users must key on the user or the scope. 'use cache' functions cannot read cookies() or headers(), and their cache key includes their arguments, so pass the user's scope in as an argument rather than caching an unscoped query.
- In Next.js 15 and later, params in a dynamic route handler is a Promise; await it, and validate the ID before using it.
- node:sqlite binds booleans only from Node.js 26.8.0; on older versions pass 1 or 0 for the admin flag, as this card does.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/typescript-nextjs does this for you).
- Against your own deployment, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes and on every server action that takes an ID. Every request that crosses a boundary must get a 404 or only in-scope rows.
- Do not expect a scanner to find this: no Semgrep registry rule or CodeQL query checks object-level authorization in Next.js, and the Semgrep packs reported nothing on the vulnerable code. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/owasp-top-ten | No findings on the route handlers or lib/impl/*.ts: the vulnerable, fixed and wrong code look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/owasp-top-ten --config p/javascript --config p/typescript --config p/security-audit app lib |
No rule targets object-level authorization in TypeScript. In github/codeql main at b5b8165, Next.qll models App Router route handlers as request sources, but no JavaScript query checks that a query is scoped to the signed-in user; the JavaScript queries tagged CWE-639 or CWE-862 check CORS credentials and empty passwords in configuration files. The regression tests are the control.
| Component | Version |
|---|---|
| Language | TypeScript |
| Runtime | Node.js 26.11.1 (node:sqlite needs 22.13.0 or later without a flag; route handlers use the default Node.js runtime) |
| Framework | Next.js 16.4.0 (App Router route handlers) |
| next | 16.4.0 |
| react, react-dom | 19.3.0 |
| node:sqlite (built in) | Node.js 26.11.1; SQLite 3.53.4 at run time |
| vitest (test only) | 5.0.3 |
| typescript (type check and next build only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/typescript-nextjsfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authorization/typescript-nextjs runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- next 16.4.0
- react 19.3.0
- vitest 5.0.3 (Vite 8.3.4)
- typescript 6.0.3
- node:sqlite built in (SQLite 3.53.4)
- semgrep 1.180.0
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
- Test results
- fixed 15/15 pass; vulnerable 6/15 fail (6 exploit tests); wrong 3/15 fail (3 exploit tests), offline after npm ci
- Dependency freshness
- next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (7.0.2 no longer exports the compiler API next build loads). npm audit found 0 vulnerabilities. tsc --noEmit passes. The tests replace lib/auth.ts with a stand-in backed by the users table (vi.mock), as the other stacks inject a test authenticator. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP Authorization Cheat Sheet
- Next.js: Data Security guide (data access layer, authorization in server functions)
- Next.js: Authentication guide (authorization checks)
- Next.js: proxy.js file convention (formerly middleware)
- GHSA-f82v-jwr5-mffw / CVE-2025-29927: Authorization bypass in Next.js middleware
- Next.js: use cache directive (cache keys and request-time APIs)
- Node.js v26.11.1: SQLite, statement.all() parameter binding