SYS://FIELD-REPORT-04

Change the ID in the URL, Read Anyone's Data

The app had real auth. Login, sessions, protected routes — the whole thing. It felt locked down, and that feeling is exactly what made it dangerous. Because the endpoint that served your invoices checked that someone was logged in, and never once checked that the someone was you. Change one number in the URL and you were reading the next account’s data. Here’s the teardown, worst finding first.

This is a representative audit — the exact pattern I hit over and over on apps built with Cursor, Claude Code, Bolt, and v0. Names and paths are changed, but every finding is real-shaped and drawn from the kind of report the launchworthy skill produces. Call the app ledgerly: a Next.js + Prisma + Postgres billing dashboard, built in Cursor, pre-launch, days from onboarding its first paying teams.

It got a scorecard. The scorecard was blunt.

0/5
domains passing
2
critical findings
3
high findings

Zero domains passing does not mean ledgerly is a bad app. It’s a good app. It means it is finished-looking, which is the precise state in which people ship — and the auth was the kind that passes a glance and fails a probe.

The finding that ends the app: authentication without authorization

Here’s the route that served an invoice. It’s src/app/api/invoices/[id]/route.ts:11, and it looks completely reasonable:

// BROKEN: any logged-in user can read any invoice by guessing the id
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const session = await getSession(req);
  if (!session) return new Response("Unauthorized", { status: 401 });

  const invoice = await prisma.invoice.findUnique({
    where: { id: params.id },   // ← no owner check
  });
  return Response.json(invoice);
}

Read it carefully, because the bug is what’s missing. There is an auth check — if (!session). That’s the trap. It confirms you’re logged in, and then it fetches the invoice by id alone. The query never asks whether this invoice belongs to the caller. So User A, fully authenticated, changes /api/invoices/inv_8841 to /api/invoices/inv_8842 and reads User B’s billing record. Sequential-ish Prisma ids make it a for loop, not a guess.

This is IDOR — Insecure Direct Object Reference — and it is the single most common critical I find in auth-heavy AI-built apps. The tool wired up login beautifully and then treated “logged in” as the finish line.

CRITICAL · CAUGHT

Broken authorization on GET /api/invoices/[id]

Any logged-in user reads any other user’s invoices by changing the id in the URL. Prisma ids are enumerable, so this is not “someone determined could get in” — it’s a script walking every account’s billing data in one pass. There was a second door, too: /admin rendered fully for any signed-in user because no middleware ever checked a role. Both are [CRITICAL].

The fix is small, and it is the same idea in two places: ownership belongs in the query, and roles belong in the middleware.

THE FIX · 20 MIN

Put the owner constraint inside the where clause so the database physically cannot hand back a row the caller doesn’t own — and derive the user id from the session, never from the URL or body.

export async function GET(req: Request, { params }: { params: { id: string } }) {
  const session = await getSession(req);
  if (!session) return new Response("Unauthorized", { status: 401 });

  const invoice = await prisma.invoice.findFirst({
    where: { id: params.id, userId: session.user.id },  // ownership in the query
  });
  if (!invoice) return new Response("Not found", { status: 404 });
  return Response.json(invoice);
}

Return 404, not 403, for someone else’s resource — a 403 confirms the record exists. Then gate /admin in middleware.ts by checking session.user.role === "admin" server-side, so the page is unreachable by URL, not just unlinked in the nav.

Two principles do all the work here. Ownership goes in the where clause, not in an if after the fetch — filtering in the query means the database can never return a row you shouldn’t see. And the user id comes from the session on the server, never from a param the client controls, because if the client can send userId, an attacker sends someone else’s.

THE FIX · IN ORDER

Close both doors

  1. Scope every by-id query to the current user. Add userId: session.user.id to the where on the invoice route, then grep the rest of src/app/api/** for findUnique({ where: { id with no owner constraint and fix each one.
  2. Move admin auth up a level. Add a role check in middleware.ts (and re-verify server-side in each admin handler) so /admin is protected by the framework, not by obscurity.
  3. Verify by acting like the attacker. Log in as User A, request an id you know belongs to User B, and confirm you get a 404.

The false alarm: “you have to be logged in to hit it”

Now the credibility move — the objection the owner raised the instant I flagged the invoice route, and the reason a lot of “review my app” passes miss this bug entirely.

FALSE POSITIVE

"It's behind auth, and the admin URL is hidden — that's access control."

It isn’t. Authentication is not authorization. “You have to be logged in to hit it” only proves the caller is a user, not that they’re the user who owns the data. Every attacker in this scenario is logged in — they signed up like anyone else. And “it’s behind an obscure URL” is not a control at all; /admin doesn’t need to be linked to be reachable, it just needs to be typed. Both excuses describe the door, not the lock.

This is why generic code review skims past IDOR: the route has an auth check, so a reviewer pattern-matching on “is there a session guard?” sees green and moves on. The bug lives in the absence of a second check that no linter flags. You only catch it by asking the ownership question on every by-id fetch — which is exactly the manual probe launchworthy demands: log in as A, request B’s id, paste the response.

The rest of the punch list

The two criticals are the blockers — do not onboard a paying team until both are closed. The rest is ranked so the scope stays honest:

FIX THIS WEEK

After the two criticals

  1. [HIGH] Permission decisions enforced only client-side. src/components/InvoiceActions.tsx:24 hides the “void” button unless user.plan === "pro", but the API accepts the void from anyone — a free user opens DevTools, flips the flag, and calls the endpoint directly. Move the plan and permission check server-side into the handler.
  2. [MEDIUM] Handlers return 200 for everything. Not-found, unauthorized, and validation failures all come back 200 with an error in the body, so clients and monitoring can’t tell success from failure. Return real status codes — 400 / 401 / 403 / 404 / 500.

Three findings survive after the criticals. Note the shape: the two ownership bugs can leak every customer’s billing data, and the rest is homework. That ranking is the point — a flat list reads as equal chores; worst-first reads as “close these two doors before anyone signs up, then breathe.”

ASK YOURSELF

“No one will find it” and “it works fine for me” are the same excuse wearing two hats — and bots wear neither. You are one honest user on a warm session who never thinks to change the id. Production is a hundred strangers, one of them curious about what happens at inv_8842, and automated scanners that enumerate ids for a living. “Works for me” is the starting line, not the finish.

Why this keeps happening

None of this means Cursor built a bad app, or that ledgerly’s owner did anything foolish. AI builders are extraordinary at getting you to looks finished — and “there’s a login page” feels like security, which is precisely the trap. The tools wire up authentication cleanly because that’s the visible, demoable half. Authorization is the invisible half: it’s the check that isn’t there, the query constraint nobody sees missing, the admin route that renders fine on your own screen because you happen to be the admin.

That gap is the whole reason launchworthy exists. It plays bouncer at the door of production. It knows that authentication is not authorization, that an obscure URL is not a lock, that a public-by-design key is fine while a missing ownership check is fatal — and it hands you the constrained where clause, not just the finding.

The hard part was never the fix; it’s four added words in a query. The hard part is knowing that the app with real, working login was the one changed id away from handing every account to the next person who typed a different number.