SYS://FIELD-REPORT-12
RLS Was On. The Server Just Walked Around It.
The owner had done the homework. Every table in the Supabase project had Row Level Security enabled — I ran select tablename, rowsecurity from pg_tables where schemaname = 'public' and got back a wall of true. That’s the check every tutorial tells you to run, and it passed. The dashboard looked locked. Then I read one API route, changed one number in a request, and pulled back another user’s orders. RLS was on the whole time. The server just walked around it.
This is a representative audit — the exact pattern I hit over and over on apps built with Lovable, Bolt, v0, and Cursor. Names and paths are changed, but every finding below is real-shaped and drawn straight from the kind of report the launchworthy skill produces. Call the app clientdesk: a Next.js App Router app on Supabase, auth working, RLS on, about to go live.
The thing that makes this one dangerous is that the usual proof of safety was present. This is a false green.
The finding: the service_role client bypasses every policy you wrote
Supabase RLS is enforced by Postgres. The anon and authenticated clients run as the logged-in user, so a using (auth.uid() = user_id) policy actually filters their rows. The service_role key is the escape hatch: it bypasses every policy on every table, by design, because background jobs and admin work need a way through. That is not a bug. The bug is using it on a route that reads user data and trusting an id the caller handed you.
Here is the route, near enough:
// app/api/orders/route.ts
const admin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
export async function GET(req: Request) {
const userId = new URL(req.url).searchParams.get('userId')
const { data } = await admin.from('orders').select('*').eq('user_id', userId)
return Response.json(data) // any id the caller types, RLS never consulted
}
The frontend calls /api/orders?userId=<my-id>, so in the demo it’s correct. But userId comes from the URL. Change it to someone else’s and the admin client — bypassing RLS — hands the rows over without a complaint. The tables still report rowsecurity = true. That’s what makes it a false green: the one check the owner knew to run says everything is fine.
CRITICAL · CAUGHT
A service_role route filtered by a caller-supplied id
app/api/orders/route.ts reads userId from the query string and queries with the service_role client, which ignores RLS. Any authenticated user — any visitor who finds the endpoint — can swap in another id and read that account’s orders. RLS being enabled on the table is irrelevant; this code walks around the wall, not through it.
There are two honest fixes, and which one you want depends on whether the route needs service_role at all. Usually it doesn’t.
THE FIX · PREFERRED
Drop the admin client. Use the request’s authenticated client so RLS does the filtering for you — the “read own orders” policy already exists, so you don’t pass an id at all:
// runs as the logged-in user; the policy filters the rows
const supabase = createServerClient(url, anonKey, { cookies })
const { data: { user } } = await supabase.auth.getUser()
if (!user) return new Response('Unauthorized', { status: 401 })
const { data } = await supabase.from('orders').select('*')If service_role was only ever there to “make the query work,” the real bug is a missing policy. Fix the policy and drop back to the authenticated client.
When you genuinely need service_role — cross-user admin work, a background job with no session, a webhook handler — keep it, but take the identity from the verified session, never from the request:
const supabase = createServerClient(url, anonKey, { cookies })
const { data: { user } } = await supabase.auth.getUser()
if (!user) return new Response('Unauthorized', { status: 401 })
// the id comes from the verified session, never from the URL
const { data } = await admin.from('orders').select('*').eq('user_id', user.id)
Two rules that close the whole class of bug: never trust a user id from a request body, query param, or header when you’re holding the service_role key; and use getUser(), which verifies the token with Supabase, not getSession(), which just reads the cookie and is not an authorization source on the server. Keep the admin client in a server-only module so it can’t be imported into a component by accident.
The false alarm: “RLS is on for every table, so I’m covered”
This is the exact sentence that lets the finding survive. It’s half-true, which is the dangerous kind.
FALSE POSITIVE
⚠️ Every table has RLS enabled. I ran the query. I'm secured.
Enabling RLS on every table is necessary and not sufficient. The policy only runs when the query runs as a user — through the anon or authenticated client. The moment a server route uses the service_role key, Postgres skips policy evaluation entirely. So “RLS is on everywhere” and “a route leaks every user’s data” are both true at the same time, on the same tables. pg_tables cannot see the problem because the problem isn’t in the table config; it’s in which key one route reached for.
The distinction worth internalizing:
- RLS enabled (the
pg_tablescheck) → confirms the wall exists. Real, necessary, and where most people stop. - Every service_role path does its own ownership check → confirms nothing walks around the wall. Invisible to
pg_tables; you find it by grepping the server, not by querying the database.
Grep your own code for the escape hatches and trace each one:
grep -rn "SUPABASE_SERVICE_ROLE\|service_role\|supabaseAdmin\|createServiceClient" \
--include="*.ts" --include="*.tsx" --include="*.js" .
For every hit, ask one question: where does the user id come from? If the answer is the request, and there’s no session check above it, that’s the leak.
Verify by hand
Log in as User A. Call the route with User B’s id in the URL. If you get B’s rows back, the finding is confirmed — paste that into your notes as evidence. After the fix, the same request must return nothing (or a 401), while A’s own data still loads. A green pg_tables is not evidence; the failed cross-user read is.
The rest of the punch list
The service_role leak is the blocker. The rest is ranked so scope stays honest:
FIX THIS WEEK
After the leak
- [HIGH] Two more service_role routes do reads an ordinary authenticated client could do under RLS — no leak yet, but the safety net is off for no reason. Move them back to the session client.
- [HIGH] No input validation on the create-order route —
req.bodywritten straight through. Add a Zod schema before it touches the table. - [MEDIUM] The admin client is imported in a shared
lib/module reachable from client components. Move it to a server-only file so it can’t leak into the bundle. - [LOW] A couple of routes use
getSession()where they meangetUser()— no active bug here, but it’s the same trust-the-cookie habit that caused the big one.
Notice the shape. One route can breach every account. The rest is real week-one work that isn’t an emergency. A flat list reads as four equal chores; worst-first reads as “fix the leak before you tweet the link, then do the rest.”
ASK YOURSELF
“But I turned RLS on. Isn’t that the whole point?” It’s most of the point, and you did the hard part. RLS is the wall. This finding is the one gate someone left propped open on the server side. You don’t tear the wall down to fix it — you make the one route that skips it prove who’s asking. Ten minutes on one file, and the check you already passed finally means what you thought it meant.
Why this keeps happening
Service_role shows up because it makes a stubborn query work. You’re building, a query returns empty because a policy is filtering it, and the fastest unblock is the key that ignores policies. It works instantly, the feature ships, and the demo — where you’re logged in as yourself and only ever pass your own id — never reveals that the id was the security boundary. The tool optimized for “the data appears,” and the data appears.
That’s the gap launchworthy exists to close. It plays bouncer at the door of production: it doesn’t stop at “RLS is on,” it greps the server for every service_role path, asks where each id comes from, and hands you the session-scoped fix — instead of a green checkmark sitting over the one route that would have breached you.
RLS on. Query passing. Dashboard green. And one line — .eq('user_id', userId) with userId from the URL — turning the whole thing into a suggestion.