Your prototype works. That is not the same as safe to sell.
Your app has login. Every sensitive route checks that a request is signed in before it does anything. That feels like access control, and for reading a dashboard it often is enough. But the routes that move money — cancel a subscription, refund an order, change a plan, add a seat, apply a credit — need a second check, and it is an easy one to leave out.
The check they have is authentication: is someone signed in? The check they are missing is authorization: is this signed-in user allowed to do this action to this record? It is one of the checks AI-built apps most commonly skip.
The launch question is concrete:
Signed in as User B, can I cancel, refund, or upgrade an order that belongs to User A?
The endpoint trusts an ID it should verify
The failure has a recognizable shape. A route reads the current session, confirms it exists, and then acts on a record identified by something in the request — a path parameter, a body field, a query string:
// vulnerable: authenticated, but not authorized
const user = await getSessionUser(req); // is SOMEONE logged in? yes
if (!user) return { status: 401 };
const orderId = req.body.orderId; // whose order? nobody checked
await refundOrder(orderId); // User B just refunded User A's order
The session check passes for every logged-in user, so the handler looks protected in a demo where you only ever test as yourself. But orderId came from the caller. Change it to someone else's order and the handler happily acts on it. This is broken object-level access control — the same class as IDOR — and on a money action it means one customer can cancel, refund, or upgrade another customer's purchase.
It hides for the same reason the RLS leak hides: the UI only ever shows you your own orders, so you never type someone else's ID. The attacker does not use your UI. They read the ID from a URL, a receipt, or a sequential guess, and call the endpoint directly.
Authorize the action against the actor
The fix is one comparison, placed before the action: the record must belong to — or be permitted for — the user making the request.
// fixed: the record must belong to the caller
const user = await getSessionUser(req);
if (!user) return { status: 401 };
const order = await getOrder(req.body.orderId);
if (!order || order.userId !== user.id) return { status: 403 }; // not yours
await refundOrder(order.id);
Returning the same 403 for both “not found” and “not yours” is deliberate — it avoids telling an attacker whether someone else's record exists. Two more things matter about where this check lives:
- It is server-side, in the handler, every time. Hiding the button in the UI is not authorization; the endpoint is the boundary. A Next.js Server Action is a POST endpoint like any other — “it's only called from my component” is not a control.
- It is per-record, not per-role. “Is this user an admin?” is a different question from “does this user own order 1041?” Most money actions are owner-scoped, and a role check alone will pass for any logged-in customer.
If your data layer already enforces ownership — a Supabase RLS policy that filters by auth.uid(), applied on the authenticated client — that same boundary can carry the check, provided the action actually runs through it and not through a service-role client that skips it. (That bypass is its own launch check.) Two cautions: RLS protects your database rows, not an external billing API — if the action calls Lemon Squeezy or Stripe with an ID from the request, RLS never sees it, so you must still fetch and verify ownership through the RLS-scoped client before that call. And an RLS-filtered UPDATE/DELETE by a non-owner silently affects zero rows instead of erroring, so assert the affected-row count, not just the absence of an exception. The invariant is the same wherever it lives: no money action executes unless the actor is authorized for the specific record.
Run the test against your own app
Money actions deserve a two-account test, exactly like data isolation — but on writes, not reads:
- As User A, create the record the action targets: an order, a subscription, a seat.
- Save its ID.
- Sign in as User B. Through the ordinary app client, call each money endpoint — refund, cancel, upgrade, apply-credit — against User A's ID.
- The pass condition: every one is rejected — 401/403, a 404 that hides whether the record exists, or even a 200 that provably did nothing — and User A's record is unchanged.
- Sign back in as User A and confirm A can still perform the action on their own record.
Do not test only through the UI, and do not test only as an admin account. Call the endpoints directly, as an ordinary second user, with the first user's IDs. For a plain Route Handler that is a direct POST. A Server Action has no friendly URL — it is invoked with a generated action ID and React's serialized arguments — so test it by replaying the real request (capture it in your browser's network tab and swap in the other user's ID) or by testing the ownership check in the function the action delegates to. Assert the state of the record after each call, not just the HTTP status — a handler can return 200 and still have quietly done the wrong thing.
If User B can move User A's money or entitlements, keep the exact request as a reproduction and turn it into a test before you change the handler. A red case you can re-run is better evidence than a fix you hope is complete.
What a passing test proves — and does not
A green two-account action test is evidence for the specific endpoints you exercised, on the paths you called. It is not a security certification.
It does not automatically cover an endpoint you forgot to test, a new money action added next sprint, an admin path with its own rules, or a background job that acts with elevated privileges. It does not prove your role model is correct, only that owner-scoped actions reject non-owners on the routes you checked.
The goal is not to call the app “secure.” It is to hold one falsifiable line:
No endpoint that moves money or grants entitlements will act on a record unless the signed-in caller is authorized for that exact record.
Keep the test in your launch suite and add a case every time you add a money action. Your prototype letting a user cancel their own order is the start. Rejecting a user who tries to cancel someone else's is the evidence you can stand behind when you sell.
See the same red/green testing pattern in the reference repo
Run the broader launch-readiness checklist
Sources: OWASP Broken Access Control and Next.js data security.
Test this on your own app before you sell it.