Let me state the conclusion first. When a Next.js × Supabase app quickly built with AI "shows other people's data," in most cases the cause is IDOR (object-level authorization flaw), and it opens not "inside" row-level security (RLS) but "outside" it — at the seam between code and SQL. And this hole structurally can't be plugged by "horizontal countermeasures" like a WAF or security headers. That's because the attack request is a "legitimate request," completely correct in both authentication and format.
This isn't a story of "don't use AI" or "Supabase is dangerous." RLS is powerful, and Supabase is robust. The problem is that authorization — a "vertical risk" — is easily overlooked by both the AI and the developer with "it worked, so it's fine." This article explains, based on real code and published primary sources, how that oversight is born, why automatic defenses can't fully prevent it, and how to systematically find and fix it.
1. What IDOR/BOLA is — "authentication passes, but authorization leaks"
First, let's pin down the terms precisely.
- Authentication = confirming "who you are." Login is this.
- Authorization = confirming "do you have the privilege over this operation/this data."
IDOR (Insecure Direct Object Reference) is the failure of the latter. In the latest OWASP API Security Top 10, under the name API1:2023 Broken Object Level Authorization (BOLA), it's positioned #1, the most serious API risk (OWASP API Security Top 10). Moreover, since the first edition in 2019, it has never ceded the top spot. It's not "a sophisticated attack that rarely happens" but "the most ordinary, most frequent leak."
The mechanism is anticlimactically simple.
GET /api/invoices/1024 ← my own invoice (legitimate access)
GET /api/invoices/1025 ← just increment the ID by one. If this returns someone else's invoice, it's IDOR
The user is correctly logged in, and the request format is also correct. The only difference is the single point that the server doesn't confirm whether the object ID (1025) contained in the URL is that user's property. In OWASP's example, a case is cited where, when you pass a vehicle's VIN (chassis number) to the API, it returns the data without verifying whether it's really the logged-in user's car.
What's important here is this nature of "a legitimate user intentionally stepping over the boundary." As described later, this becomes the root cause of why "automatic defenses can't prevent it."
2. Why does Supabase code written by AI mass-produce IDOR
On the security of AI-generated code, there's measured data, not speculation. In a 2025 study where Veracode imposed 80 coding tasks across 4 languages on 100+ LLMs, 45% of the code AI generated contained known security flaws, and only 55% was safe (Veracode 2025 GenAI Code Security Report). Even more important is that even as models got bigger and smarter, the security grade stayed flat. The code became "more functional" but not "more secure."
Why? AI writes code that achieves "what you want to do (= what works in the demo)" instructed by the prompt by the shortest path. "Don't show other people's data" is outside the happy path unless explicitly requested, and never surfaces in a demo. As long as you touch it with your own account, no one performs the operation of rewriting the ID to peek at others.
In the Supabase combination, this general tendency converges to two fixed failure modes.
| Failure mode | What happens | Relationship with RLS |
|---|---|---|
| ① RLS-unset exposure | Forgetting to enable RLS on a table, all rows are readable from a public API with the anon key | RLS is "absent" |
| ② Bypass with service_role | Using the service_role key server-side, receiving an ID with no ownership check | "Ignoring" RLS |
These two are published as real serious incidents. CVE-2025-48757, registered in 2025, is the typical example. The NVD's official description goes like this — "Due to Lovable's (through 2025-04-15) insufficient Row-Level Security policies, a remote unauthenticated attacker can read and write arbitrary DB tables of a generated site." The classification is CWE-863 (Incorrect Authorization), and the CVSS base score is 9.3 CRITICAL.
What's notable is that this CVE is marked as "disputed" by the vendor. The platform side's claim is "protecting the app's data is the user's responsibility." Whether or not that claim is valid, as a fact, this CVE eloquently tells us that IDOR/authorization is "your area of responsibility" that the platform won't take over. Precisely because you can't get by just by purchasing, you have no choice but to defend yourself with design and verification.
Below, I break down the two failure modes with real code.
3. Failure mode ①: exposed to a public API with RLS not enabled
Supabase automatically exposes PostgreSQL tables as a REST API via PostgREST. Convenient, but this means "a table where RLS isn't enabled is readable by anyone who knows the anon key." The anon key is a public key distributed to the browser; it's not a secret.
-- 危険:RLSを有効化していないテーブル
create table profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users,
full_name text,
phone text,
stripe_customer_id text
);
-- ↑ enable row level security を書き忘れると…
-- curl "https://<project>.supabase.co/rest/v1/profiles?select=*" \
-- -H "apikey: <anon-key>"
-- が、全ユーザーの氏名・電話番号・Stripe顧客IDを返してしまう
This is the essence of what happened in CVE-2025-48757. Even if the SQL to create the table can be generated, the operational discipline "always stretch RLS on the premise of being exposed to a public API" isn't included in the AI's default output.
The fix is simple, but it's important that it's two stages.
-- ステップ1:RLSを有効化する(これ自体が fail-secure =デフォルト全拒否)
alter table profiles enable row level security;
-- ステップ2:必要なアクセスだけを明示的に許可する
-- 有効化しただけでポリシーが無い状態は「誰も読めない」。
-- そこから、自分の行だけ読める許可を足す。
create policy "users read own profile"
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
There are two points.
- Enabling RLS is fail-secure. If there isn't a single policy, the default is "deny all." If you "just enable it for now," at least the defenseless full exposure stops.
- Wrap
auth.uid()in(select auth.uid()). This is the way Supabase officially recommends; because it doesn't re-evaluate the function per row but caches it in the initial plan, on a large table the performance changes by orders of magnitude. If you write the plainauth.uid() = user_id, you fall into the trap of "it works correctly but is slow."
However — it's not "stretch RLS on all tables and you're done." This is the core of this article. The next failure mode ② disables RLS wholesale even when it's perfectly stretched.
4. Failure mode ②: "bypassing" RLS with the service_role key while forgetting the ownership check
Supabase has two kinds of server keys. The anon key that respects RLS, and the service_role key that completely ignores RLS. The latter is authenticated as the service_role, which has PostgreSQL's BYPASSRLS attribute. The Supabase official documentation clearly states — "The service_role key completely bypasses Row Level Security. Use it only server-side." "Adding service_role to an RLS policy has no effect whatsoever, because service_role doesn't run inside RLS but jumps over RLS itself" (Supabase: Row Level Security).
What happens here? An AI agent (or a developer in a hurry) succumbs to the temptation, in a server-side Route Handler, of "rather than agonizing over RLS policy settings, using the admin client surely works." And it bypasses RLS with service_role while forgetting to write the ownership check.
The vulnerable code: service_role key × no ownership check
// app/api/invoices/[id]/route.ts — 脆弱(IDOR)
import { createClient } from "@supabase/supabase-js";
// service_role キー:RLS を完全にバイパスする“管理者”クライアント
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; // ← クライアントが自由に変えられる値(汚染された入力)
const { data, error } = await supabaseAdmin
.from("invoices")
.select("*")
.eq("id", id) // ← 所有権の条件が一切ない=IDOR
.single();
if (error) return Response.json({ error: error.message }, { status: 404 });
return Response.json(data); // 他人の請求書もそのまま返る
}
The terror of this code is that even if a perfect RLS policy is stretched on the invoices table, it has no effect at all. Because service_role jumps over RLS. Even in a field that perfectly accomplished the countermeasure of failure mode ① (stretching RLS), if there's a single such route, everything leaks. The attack, as mentioned, is just rewriting /api/invoices/1024 to /api/invoices/1025.
This flow of "an input the client can manipulate (params.id) reaches a dangerous sink (a DB query) without being narrowed by ownership" I call tainted-scope IDOR, and target for detection with the static-analysis rule described later.
Fix A (recommended): leave authorization to the DB — make the user's RLS work
The most robust is not using service_role in the first place, but using an anon-key client that runs with the user's session. This way, authorization is enforced by the DB's RLS, and even if the code forgets to write the ownership check, it falls to the safe side (the "last line of defense" of defense-in-depth becomes the DB).
// app/api/invoices/[id]/route.ts — 修正案A:RLSに認可を任せる
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ← anon キー=RLSが効く
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: () => {}, // 読み取り専用ルートなので省略
},
},
);
// RLS が「自分の行」しか返さないため、IDを書き換えても他人の行は0件になる
const { data, error } = await supabase
.from("invoices")
.select("*")
.eq("id", id)
.single();
if (error) return Response.json({ error: "not found" }, { status: 404 });
return Response.json(data);
}
The corresponding RLS policy:
alter table invoices enable row level security;
create policy "owners read their invoices"
on invoices for select
to authenticated
using ( (select auth.uid()) = user_id );
The strength of this design is that "you can erase authorization from the code." The ownership decision is centralized in the DB, and the risk of the developer or AI forgetting to write .eq("user_id", ...) every time they add a new route disappears. From the ETC (Easy To Change) viewpoint too, the authorization logic is consolidated in one place, and changes are localized.
Fix B: if service_role is needed, "always" enforce ownership in code
There are also routes that really need to cross RLS, like admin panels and batches. In that case, thoroughly apply as discipline these two points — determine "who it is" on the server, and always bind ownership in the WHERE clause.
// 修正案B:service_role を使うなら、所有権をコードで強制する
import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
// 1) 誰なのかをサーバーで確定する(クライアントの自己申告を信じない)
const user = await getAuthenticatedUser(req);
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });
// 2) service_role でも、所有権を必ず WHERE で縛る
const { data, error } = await supabaseAdmin
.from("invoices")
.select("*")
.eq("id", id)
.eq("user_id", user.id) // ← この1行が無いと IDOR
.single();
if (error || !data) return Response.json({ error: "not found" }, { status: 404 });
return Response.json(data);
}
Fix B works correctly, but you should understand that it's fragile. The moment you forget to write .eq("user_id", user.id), a hole opens, and neither the compiler nor RLS will help you. So the principle is A (lean authorization onto the DB), and B is positioned as "an exception, monitored intensively in review, only on routes that have a reason to cross."
Server Actions fall into the same hole. A
"use server"action is effectively a POST endpoint, and an ID received viaformData.get("id")or an argument is equally client-manipulable. "It's safe because the ID isn't shown on screen" doesn't hold. If you hit it over HTTP, anyone can send an arbitrary value. Always apply the same ownership rule.
5. Why a WAF, security headers, or "as long as there's RLS" can't fully prevent it
So far we've seen that IDOR opens at "the seam between code and SQL," and that even stretching RLS, it slips out via the service_role path. So why can't the "security products" of the world automatically plug it? Because security countermeasures have an automatable "horizontal" and a non-automatable "vertical."
| Horizontal countermeasures (automatable) | Vertical risks (can only detect/warn) | |
|---|---|---|
| Examples | security headers/CSP, rate limiting, CSRF, input validation, secret-leak prevention | authorization (IDOR/BOLA), business-logic flaws, privilege escalation |
| Nature | works uniformly across apps | depends on the app-specific "who owns what" |
| Automation | pluggable with a library/config | unpluggable by a library (it doesn't know your data model) |
The reason a WAF can't prevent IDOR is fully captured in this table. The request GET /api/invoices/1025 is completely normal as HTTP. The authentication header is correct too, and it doesn't contain an invalid string like SQL injection. From the WAF's view, this is "a legitimate user's legitimate request." It becomes an attack due to the app-specific business fact "invoice #1025 isn't this user's property" — and the WAF doesn't know your data model.
Similarly, security headers and CSP work for XSS and clickjacking but are unrelated to authorization. RLS is indispensable, but as seen in section 4 it's jumped over on the service_role path, and there also remain areas where RLS doesn't work (SECURITY DEFINER functions, RPCs, Storage, externally-joined tables).
The conclusion is this. A product where "introducing something protects authorization" doesn't exist, and a product that claims to exist is rather dangerous. The very complacency of "I put it in, so it's fine" produces the worst security outcomes. Authorization can only be protected by the two wheels of secure design (RLS + code ownership checks) and the verification that backs it up. What can be automated is up to "detecting/warning" the correctness of the design.
6. Systematically "finding" IDOR — three layers of verification
Once you've decided to plug it with design, the next question is how to confirm "is it plugged." Following the verification-first principle, systematize it in three layers. The point is not to rely on a single means, but to stack static, structural, and dynamic.
Layer 1: static analysis (SAST) — follow the code's data flow
Follow, with intra-function data flow (taint analysis), whether "a client-manipulable input (route params, search query, request body, cookie) reaches a DB query without being narrowed by ownership." In the vulnerable code of section 4, it mechanically picks up the pattern "params.id is passed to .eq("id", ...) but goes through neither .eq("user_id", ...) nor auth.uid()."
This can't be structurally written with regex. That's because you need to follow "where the input comes from and where it flows." I implement this detection in my self-made OSS scanner Aegis as a rule called authz/idor-tainted-scope.
# インストール不要・設定不要でスキャン
npx @aegiskit/cli scan
Layer 2: SQL/RLS verification — does authorization correctly reside "in the DB"
Separately from the code, read supabase/migrations/**.sql and verify the authorization design itself. Concretely — tables with RLS disabled, write policies with no WITH CHECK, unconditional permits like using (true), excessive privileges to the anon role, SECURITY DEFINER functions that don't fix search_path (a hotbed of privilege escalation). And cross-check SQL and code, pointing out, as a confirmed exposure, "a place that actually queries an RLS-weak table from a non-admin client." Aegis does this too by parsing supabase/migrations.
Layer 3: dynamic confirmation (DAST) — actually hit it "as someone else"
Static analysis goes only to "suspecting." Finally, against an app you own, actually reproduce IDOR and confirm it. The method is simple.
- Prepare two identities for testing (user A and user B)
- Log in as A and obtain A's resource ID (e.g., invoice 1024)
- While still in A's session, hit an ID pointing to B's resource
- If 200 is returned and you can see someone else's data, IDOR is confirmed at runtime
# 自分のアプリに対する安全・非破壊なプローブ(所有権の食い違いを実行時に確認)
aegis probe http://localhost:3000 --correlate
What matches between static analysis's "suspicion" and dynamic's "reproduction" is fixed first, as confirmed-exploitable. This is the SAST↔DAST correlation. In addition, to prevent RLS regression, write regression tests of the policies with pgTAP and continuously prove in CI that "other people's rows can't be seen."
The honest scope — a tool helps "finding" but doesn't prove "correctness"
Let me emphasize here. No static or dynamic tool can prove that your authorization is correct. What the tool sees is the "form" of the policy or implementation, not the meaning of your business rules or data model. A clean result is "it doesn't step on the common traps," not "authorization is correct." So these three layers are not a replacement for human review and threat modeling but a complement to them. Even so, the value of mechanically crushing the most frequent holes is immeasurable. Detection gets "teeth," doesn't increase noise, and humans become able to focus on the genuinely-hard judgments.
7. An IDOR checklist for clients and teams
Whether it's outsourced code or code you had AI write, confirm at least this before going to production. To make it judgeable even by a non-expert, here are the viewpoints and confirmation methods.
| Viewpoint | What to confirm | Danger signal |
|---|---|---|
| RLS enablement | Is RLS enabled on all tables | There's a table without enable row level security |
| Location of service_role | Isn't the service_role key passed to the client/browser | service_role is visible in front-end code or the network tab |
| Ownership on the service_role path | Does an API using service_role have an ownership condition like user_id | It receives an ID and returns with only .eq("id", ...) |
| ID-swap test | With two accounts, is hitting someone else's ID rejected | Another user's data is returned with 200 |
| Areas where RLS doesn't work | Is there no hole in RPC / SECURITY DEFINER functions / Storage / join targets | Thinking stops at "I stretched RLS, so it's safe" |
| Automation of verification | Are scanning, RLS verification, and regression tests in CI | There's no verification basis other than "it worked on my machine" |
What's most effective from the client's viewpoint is the two questions "What happens if I hit someone else's ID?" and "Where do you use the service_role key?" If they can't answer clearly, there's a high possibility that authorization verification isn't built into the design. A good developer can answer these immediately.
Frequently asked questions (FAQ)
Q. If I enable RLS on all tables, can I prevent IDOR?
A. You can prevent much of it, but it's insufficient. As in section 4, the service_role path jumps over RLS, and there also remain areas where RLS doesn't directly work, like SECURITY DEFINER functions, RPCs, Storage, and external join targets. RLS is mandatory as the "last line of defense," but protecting in two layers with the code-side ownership check is the correct answer.
Q. Must I never use the service_role key? A. No. There are legitimate uses in admin processing and batches. There are two iron rules — (1) use it only server-side (never expose it to the browser), (2) always enforce ownership in code. Don't use it on a route where you can't keep these two.
Q. Is it enough to put in a WAF or security headers? A. They don't serve as an IDOR countermeasure. As in section 5, IDOR is a "legitimate request" correct in both authentication and format, so a WAF can't distinguish it as an attack. They're effective for "horizontal" risks like XSS and injection, and are a separate layer from authorization. Both are needed, but one doesn't substitute for the other.
Q. If I ask the AI to "write it securely," is it fixed? A. Don't expect too much. In Veracode's study, even as models got smarter, the security grade stayed flat. AI is strong at generating "working code" but doesn't guarantee a "structure that doesn't break." It becomes production quality only after you leverage AI's speed while passing through verification gates (scanning, tests, review).
Q. Even for personal development or small scale, should I go this far? A. Rather, the reality that CVE-2025-48757 and others show is that apps quickly built with AI have many exposure cases. Even in a minimal composition, always do at least the three: "RLS on all tables" + "ownership check on the service_role path" + "one ID-swap test with two accounts." The cost is slight, and the size of the accidents you can prevent is orders of magnitude larger.
Summary: the defensive boundary moves from "whether RLS exists" to "where the key and ownership are placed"
Let me organize the key points.
- IDOR (OWASP API1:2023 BOLA) is the most frequent and most serious API risk. It's an authorization failure where a legitimate user rewrites an ID and touches someone else's data.
- The reason this gets mass-produced in AI-made Next.js × Supabase code is the two fixed failure modes: (1) RLS-unset exposure and (2) RLS bypass by the service_role key + forgetting the ownership check. CVE-2025-48757 is that reality.
- Because service_role completely jumps over RLS, the defensive boundary moves from "is there RLS" to "where you place the service_role key and where you enforce ownership."
- A WAF or headers can't prevent it. That's because IDOR is a legitimate request, and a "vertical risk" where only your data model decides whether it's an attack. You can't get by just by purchasing.
- Find it in three layers — code taint analysis, SQL/RLS verification, runtime confirmation. But a tool only helps "finding"; the correctness of authorization can only be protected by design and human review.
This "IDOR that opens outside RLS" is exactly the class of flaw I target for detection with my self-made security tool Aegis (npx @aegiskit/cli scan), and an area I've actually plugged in both contract and in-house development. Building fast with AI is itself correct. Firming up what you built fast, safely, without leaks — if you need to build the verification mechanism for that, or an authorization review of an existing Next.js × Supabase app, please feel free to consult me.
References
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization
- NVD — CVE-2025-48757 (Lovable / unauthenticated access via insufficient RLS, CWE-863, CVSS 9.3)
- Supabase Docs — Row Level Security (service_role bypasses RLS)
- Veracode — 2025 GenAI Code Security Report (45% of AI-generated code has security flaws)