You enabled Row Level Security. You ran the two-user test. User B could not read User A's row through the ordinary client. That work is real—and a single line in a server route can quietly undo all of it.
Supabase gives your project two kinds of keys. The anon (publishable) key makes requests that Row Level Security evaluates. The service-role (secret) key is a trusted server credential that bypasses RLS entirely. A query made with the service-role key does not run your policies. It runs as the owner of the data.
That is exactly what the key is for. It is also exactly how an AI-built app leaks across accounts while every RLS policy you wrote sits untouched in the database.
How the bypass gets into a vibe-coded app
Nobody decides to disable their access boundary. It arrives by accident, and usually one of two ways.
The first is scaffolding. A code generator spins up a Next.js Route Handler or Server Action and reaches for SUPABASE_SERVICE_ROLE_KEY, because a server key "just works" and never trips an authorization error during a demo.
The second is a fix that made a red error go green. A developer hits permission denied for table while wiring a feature, searches for the fastest way past it, and swaps the client over to the service-role key. The error disappears. So does RLS on that path.
Either way, the shape is the same:
// app/api/documents/[id]/route.js
import { createClient } from "@supabase/supabase-js";
// This client ignores every RLS policy on the table.
const admin = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
);
export async function GET(request, { params }) {
const { data } = await admin
.from("documents")
.select("*")
.eq("id", params.id)
.single();
return Response.json(data);
}
The route takes an ID from the request and returns the row. Because the query runs with the service-role key, it does not check who is asking. User B does not need to touch your interface. They call the same endpoint with User A's document ID and receive User A's document.
The useful launch question is concrete:
Does this route return the row because the caller is allowed to see it, or only because the key it uses cannot say no?
Separate "trusted" from "on behalf of a user"
The service-role key is not the problem. Using it on a path that returns user-supplied-ID data to the caller is the problem.
A server route acting on behalf of a signed-in user should run as that user, so RLS still applies. In Next.js, that means a request-scoped client built from the user's session, not a module-level admin client:
// The client carries the user's identity; RLS is evaluated as User B.
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{ cookies: cookies() }
);
The service-role key is for genuinely trusted server work that must bypass RLS by design: writing an entitlement from a verified webhook, an admin job, a migration task. Those paths carry their own rule—they must do their own authorization and must never take an untrusted ID from the request and hand back the row it names.
The distinction is not "server versus client." Both clients above run on the server. The distinction is whose authority the query runs under.
Run the bypass test against your own app
A grep tells you where the key is. A request tells you what it does. Do both.
First, find every place the service-role key is loaded:
- Search your codebase for
SERVICE_ROLE,service_role, and the client that receives it. - For each result, answer one question: can the caller influence which row this path returns or changes? If yes, it needs its own authorization check—or it should not be using the service-role key at all.
Then prove the boundary with two ordinary accounts, against the real endpoint:
- Sign in as User A and create a private row. Save its exact ID.
- Sign out, then sign in as User B through the ordinary app client.
- Call the actual route that reads that resource—the same URL your app calls—as User B, requesting User A's saved ID.
- Assert the response is a refusal or empty, never User A's row.
- Repeat for every operation the route permits: read, update, and delete.
- Sign back in as User A and confirm the intended own-row flow still works.
Do not test this by calling Supabase directly with the service-role key. That measures the key, not your route. Drive the request the way a real user's browser would—through the endpoint, with an ordinary user's session.
If User B receives or changes User A's data, keep the exact request, response, identities, and minimal fixture. Turn that reproduction into a test before you change the route. A stable red case is evidence that your fix addresses the failure you actually found.
Know what green does—and does not—mean
A passing test on one route is evidence for one path. It is not a clean bill of health for the service-role key across your app.
It does not prove that your other routes, Server Actions, RPCs, or cron jobs use the key safely. It does not prove that a route which is safe today stays safe after the next change. And a green result here does not replace the RLS work itself—the two-user RLS test checks that your policies are correct; this checks that a server route did not route around them.
Both matter, because they fail independently. Correct policies do nothing on a path that bypasses them. A user-scoped path does nothing if the underlying policy is wrong.
Keep both tests in the launch suite. Run them when you add a route, change an authorization path, or touch how the app talks to Supabase.
The goal is not to call the app "secure." The goal is to preserve a falsifiable statement:
Every route that returns or mutates a user's row runs under that user's authority, and User B cannot reach User A's data through any of them.
Your prototype working is the start. A route that cannot be tricked into answering for the wrong user is evidence you can sell behind.
Run the broader launch-readiness checklist
Sources: Supabase API keys and the service role and Row Level Security.
Test this on your own app before you sell it.