Let me state the conclusion first. Supabase's anon key is "a key you may publish" — but the major premise is that RLS (row-level security) is taking effect on all the exposed tables. The service_role key, on the other hand, is "a key you must absolutely never expose to the client," and the moment it leaks, all data is exposed. Because service_role runs with PostgreSQL's BYPASSRLS privilege and leaps over the RLS policies you carefully wrote, wholesale.
In other words, which key you use, where — that choice becomes the attack surface itself. A key is not mere authentication info but a switch that toggles "whether the defense called RLS takes effect or not." Choose anon, and the DB's RLS protects you as the last bastion; the moment you choose service_role, authorization (who may access what) becomes entirely your code's responsibility.
This article explains the true nature of these 2 keys, the mechanism by which service_role becomes "instant death," and the design of where to place "key placement" and "ownership checks," in real Next.js App Router code. The key story doesn't complete on its own. The overall defense design of Next.js × Supabase is summarized in the comprehensive guide, but within it, "key handling" is the one point where accidents are most frequent and most irreversible.
1. Conclusion: the key choice becomes the attack surface itself
Let me illustrate the key points first. A Supabase client's nature flips 180 degrees by "which key you make it with."
| anon key (publishable) | service_role key (secret) | |
|---|---|---|
| May you distribute it | You may (public key) | Absolutely not (secret key) |
| Placement | Both browser and server OK | Server-only |
| RLS handling | RLS takes effect (runs with the user's privileges) | Completely ignores RLS (BYPASSRLS) |
| Authorization responsibility | The DB's RLS enforces it | 100% your code's responsibility |
| Damage on leak | Limited if RLS is correct | All tables exposed (instant death) |
The last row of this table is everything about this article. The anon key, even if leaked — or rather, since it's distributed to the browser from the start there's no concept of "leaking" — has limited damage if RLS is correctly applied. The moment the service_role key leaks, the attacker becomes the administrator of your database. No matter how perfect RLS is, it's irrelevant.
So the defense boundary moves not to "is there RLS or not" but to "where you place the secret key and where you enforce ownership." Hereafter, let me decompose that boundary one step at a time.
2. The true nature of the 2 keys — anon (public key) and service_role (a server-only key that runs with BYPASSRLS)
The anon key: a "public key" designed to be distributed to the browser
The anon (anonymous) key is a public key designed on the premise of being embedded in the browser. The Supabase official documentation explicitly states about this key, "it can be used safely in client-side code. But only when you have Row Level Security enabled" (Supabase: API Keys).
This is the first pitfall. If just "anon may be published" runs ahead on its own, the condition "only when RLS is taking effect" drops out. If there's a table you forgot to apply RLS to, that public key turns into "the key to a front door anyone can enter."
# RLS未設定のテーブルは、公開鍵 (anon) だけで誰でも全件読める
curl "https://<project-ref>.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: <anon-key-これは公開情報>"
# → RLS が無ければ全ユーザーの氏名・電話番号・stripe_customer_id が返る
The anon key "may be published" precisely because RLS stands as a barrier. The typical examples where this premise collapses — a missed RLS enablement, or an unconditional permission like using (true) — are handled in detail in the RLS Misconfiguration Detection Guide. Always think of the key and RLS as a set.
The service_role key: a "server-only secret key" that leaps over RLS
The service_role key is at the opposite pole. The Supabase official repeatedly warns "this key completely bypasses Row Level Security. Use it only on the server side" (Supabase: API Keys).
Technically, a request authenticated with the service_role key is executed as the role service_role on PostgreSQL. This role has the BYPASSRLS attribute. The PostgreSQL official documentation's definition is this — "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table" (PostgreSQL: Row Security Policies).
Let me line up the two in actual client-generation code.
// (1) ブラウザ/ユーザーセッションで動くクライアント — anon(publishable)キー
// RLS が効く。ブラウザに配ってよい鍵。
import { createBrowserClient } from "@supabase/ssr";
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ← NEXT_PUBLIC_ でOK(公開前提)
);
// (2) サーバー専用クライアント — service_role(secret)キー
// RLS を完全にバイパスする。絶対にブラウザへ出さない。
import "server-only"; // ← クライアントから import したらビルドエラーになる(後述)
import { createClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // ← NEXT_PUBLIC_ を絶対に付けない
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
(1) is "a client bearing the user's privileges," and (2) is "an administrator client with full powers." They look like the same @supabase API, but the guards that take effect are completely different.
Note: key naming is evolving. Supabase has introduced, as a new format, a publishable key (
sb_publishable_...) and a secret key (sb_secret_...), the former browser-safe (the oldanonequivalent), the latter server-only (the oldservice_roleequivalent) (Supabase: API Keys). The names change, but the boundary is the same — the dichotomy of "a key you may expose to the browser" and "a key you must not expose from the server" is invariant. In this article, I unify with the conventional namesanon/service_role.
3. Why a service_role leak is "instant death" — BYPASSRLS leaps over RLS
RLS has "parties it takes effect on" and "parties it doesn't"
Understanding this precisely is the foundation of every design judgment. RLS is not an all-purpose wall; it takes effect or doesn't depending on the role. Organizing the PostgreSQL official documentation's description, it's this (PostgreSQL: Row Security Policies).
- Superuser /
BYPASSRLSrole → always bypasses RLS (=service_roleis here) - The table's owner → normally bypasses RLS. But set
FORCE ROW LEVEL SECURITYand you can apply RLS to the owner too - Other general roles → RLS takes effect (=
anon/authenticatedare here)
That is, RLS takes effect on a request that came with the anon key, and doesn't on a request that came with the service_role key. Even with the same table and same policy, the result changes depending on which key it came with.
Even FORCE ROW LEVEL SECURITY can't stop service_role
You might think "if the owner bypass is scary, just attach FORCE." Indeed, it's effective for blocking the owner bypass.
alter table invoices enable row level security;
alter table invoices force row level security; -- テーブル所有者にもRLSを強制する
create policy "owners read their invoices"
on invoices for select to authenticated
using ( (select auth.uid()) = user_id );
But there's an important caveat. FORCE ROW LEVEL SECURITY takes effect on the table owner but not on the BYPASSRLS role (= service_role). Because by PostgreSQL's spec, BYPASSRLS "always bypasses," and the table-side setting can't override it. So "binding service_role with a DB-side setting" is fundamentally impossible. The only way to control service_role is to physically confine the code that holds that key inside the server — that's all there is to it.
The true nature of "instant death": NEXT_PUBLIC_ mixing and baking into the client bundle
So how does service_role, which should be server-only, leak to the browser? The 2 most common accidents in Next.js are the following.
// ❌ 事故その1:秘密鍵に NEXT_PUBLIC_ を付ける
// → Next.js は NEXT_PUBLIC_ で始まる環境変数をビルド時にクライアントバンドルへ焼き込む
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, // ← 公開JSに混入=全データ露出
);
// ❌ 事故その2:"use client" のファイルでサーバー専用クライアントを import する
// → モジュールごとブラウザに送られ、秘密鍵が同梱される
"use client";
import { createAdminClient } from "@/lib/supabase/admin";
export function Dashboard() {
const admin = createAdminClient(); // クライアントで全権クライアントが動く=即アウト
// ...
}
The dread of accident #1 is that neither locally nor in a demo does any error appear. It works, it's fast, and you don't have to agonize over RLS. So both an AI agent and a developer in a hurry tend to attach NEXT_PUBLIC_. And it's deployed to production with the secret key baked into the build artifact. The attacker just opens the JS bundle in the browser's dev tools and picks up one string starting with eyJ.... From there, with RLS and policies entirely irrelevant, all tables become free to read and write.
What an attacker can do with a leaked service_role key
Not abstract argument — let me show what actually happens in the hands of an attacker who holds a leaked secret key. Because Supabase exposes all tables as a REST API via PostgREST, with just the key you don't even need the client library.
# 流出した service_role キーがあれば、RLS を無視して全テーブルを全件読める
curl "https://<project-ref>.supabase.co/rest/v1/invoices?select=*" \
-H "apikey: <leaked-service-role-key>" \
-H "Authorization: Bearer <leaked-service-role-key>"
# 読むだけではない。管理者として書き換え・削除も通る
curl -X DELETE "https://<project-ref>.supabase.co/rest/v1/invoices?id=eq.1025" \
-H "apikey: <leaked-service-role-key>" \
-H "Authorization: Bearer <leaked-service-role-key>"
Neither user ID, nor ownership, nor policy is asked at all. Because service_role is "full read/write power over all tables." You can pull others' email addresses from auth.users, and rewrite the billing table.
A service_role leak should be called not "information leakage" but "instant death" because the damage doesn't stay in a single table or single user but reaches the entire database. One mistake makes all of RLS meaningless. That's exactly why detection and recovery are too late "after it leaks," and a design that doesn't leak (Section 5) becomes the essence.
4. The commandments for using service_role "correctly" — ownership is entirely the code's responsibility
service_role is not "a key you must not use." There are legitimate uses that bypass RLS: an admin screen, batch processing, Webhook reception, aggregation spanning multiple users, etc. The problem is not "using it" but "forgetting to write the ownership check while the safety net of RLS is off."
There are only 2 commandments. (1) Use it only inside the server (Route Handler / Server Action). (2) Always enforce ownership in code. Because RLS doesn't take effect, authorization becomes 100% your responsibility.
Vulnerable code: service_role × no ownership check
// app/api/invoices/[id]/route.ts — 脆弱(IDOR)
import { createAdminClient } from "@/lib/supabase/admin";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; // ← クライアントが自由に変えられる値
const supabase = createAdminClient(); // ← RLS を飛び越える鍵
const { data, error } = await supabase
.from("invoices")
.select("*")
.eq("id", id) // ← 所有権の条件が一切ない
.single();
if (error) return Response.json({ error: error.message }, { status: 404 });
return Response.json(data); // 他人の請求書もそのまま返る
}
This code, even if a perfect RLS policy is applied to invoices, doesn't take effect at all. Because service_role leaps over RLS. The attack is simple — just rewrite /api/invoices/1024 to /api/invoices/1025. This is API1:2023 Broken Object Level Authorization (BOLA / IDOR), positioned at #1 in the OWASP API Security Top 10 (OWASP API1:2023). For an explanation narrowed to the discovery and fix of this flaw, see the IDOR dedicated guide.
Fix A (recommended): don't use service_role in the first place; leave authorization to RLS
The most robust is not using service_role on this path. Switch to an anon-key client that runs in the user's session, and authorization is enforced by the DB's RLS, and even if you forget .eq("user_id", ...), it falls to the safe side (the last bastion becomes the DB).
// app/api/invoices/[id]/route.ts — 修正案A:anon キー+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 );
Just in case — adding service_role to this policy is meaningless. As the Supabase official explicitly states, service_role doesn't run inside RLS but leaps over RLS itself, so writing it in a policy has no effect (Supabase: Row Level Security). If you want to control service_role, you have no choice but to address it with "key placement," not a policy (Section 5).
Fix B: if service_role is needed, "always" enforce ownership in code
There are also paths that truly need to bypass RLS, like an admin screen or a batch. In that case, make these 2 points a discipline: confirm "who it is" on the server, and always bind ownership in the WHERE clause.
// app/api/invoices/[id]/route.ts — 修正案B:本人を確定し、所有権を WHERE で強制
import { createAdminClient } from "@/lib/supabase/admin";
import { getAuthenticatedUser } from "@/lib/auth";
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 supabase = createAdminClient();
const { data, error } = await supabase
.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 be aware it's fragile. The moment you forget .eq("user_id", user.id), a hole opens, and neither the compiler nor RLS helps you. So the principle is Fix A (lean authorization onto the DB's RLS). Position Fix B as "an exception, watched intensively in review, only on paths with a legitimate reason to bypass."
Multi-tenant SaaS especially needs care. You need to bind not just by
user_idbut alsotenant_id/organization_id. Forget thetenant_idnarrowing on theservice_rolepath, and all of the neighboring tenant's data leaks. The verification procedure for cross-tenant leaks is summarized in the Multi-Tenant Verification Guide.
Server Actions fall into the same hole. A
"use server"action is effectively a POST endpoint, and the ID received via an argument orformData.get("id")is equally client-manipulable. "It's safe because the ID isn't shown on screen" doesn't hold. If you useservice_role, always apply the same ownership rules as a Route Handler.
5. The design of key placement — the environment-variable boundary and the NEXT_PUBLIC_ trap
As seen in Section 3, you can't bind service_role on the DB side. What you can protect is only "placement." Let me fix this as design.
The environment-variable boundary: NEXT_PUBLIC_ is the sole judgment of "goes to the client / doesn't"
Next.js's rule is clear. Only environment variables starting with NEXT_PUBLIC_ are baked into the client bundle, and the rest exist only on the server. So the naming itself becomes the boundary.
# .env.local(.gitignore に入れ、絶対にコミットしない)
# 公開してよい — ブラウザに焼き込まれる前提
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<publishable-key-公開してよい>
# 絶対に公開しない — NEXT_PUBLIC_ を付けない=サーバーにしか出ない
SUPABASE_SERVICE_ROLE_KEY=<secret-key-絶対に貼らない>
Whether you attach NEXT_PUBLIC_ to SUPABASE_SERVICE_ROLE_KEY or not — just one prefix separates "the server's safe" from "published to the whole world." This is the true nature of Section 3's "accident #1."
The server-only boundary: stop it physically at the point of import
Naming discipline is something humans get wrong. So put in a mechanism that stops it mechanically, doubly. Next.js's server-only package fails the build the moment that module is imported from a client component.
// lib/supabase/admin.ts
import "server-only"; // ← "use client" 配下から import されると即ビルドエラー
import { createClient } from "@supabase/supabase-js";
export function createAdminClient() {
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); // 起動時に気づく
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
This crushes "accident #2 (import from the client)" at build time. Make it a convention that code touching service_role is always confined inside this server-only boundary.
Post-leak confirmation: is the secret key's "value" not left in the build artifact
Once you've designed it, verify (verification first). Actually confirm that the secret key's value isn't mixed into the static assets distributed to the client.
# ビルド後、クライアント静的アセットに秘密鍵の"値"が混入していないか確認
npm run build
grep -rF "$SUPABASE_SERVICE_ROLE_KEY" .next/static \
&& echo "DANGER: 秘密鍵がバンドルに混入。直ちにローテーション" \
|| echo "OK: 静的バンドルに秘密鍵の値は無い"
# 注: $SUPABASE_SERVICE_ROLE_KEY をシェルに読み込んだ上で実行。値はログに出さない。
It's normal for it to be included in .next/server (the server-side chunks). The problem is whether it appears in .next/static (distributed to the browser). If even one hit, treat that key as already leaked and immediately rotate it in the Supabase dashboard.
Secret mixing is a domain that "can be detected mechanically"
This is good news. A mis-attachment of the secret key to NEXT_PUBLIC_, or secrets committed to the repository (keys, tokens), can be detected by shape (pattern) alone without knowing the data model — that is, it's a domain where automation takes effect. Many OSS scanners have secret scanning, and my published Aegis too includes this kind of "NEXT_PUBLIC_ × secret key" and "committed secret" in its detection rules with npx @aegiskit/cli scan. Unlike the correctness of authorization (ownership) seen in the next section, this is a place to leave to the machine and crush the misses.
6. Think of keys and RLS as one — what CVE-2025-48757 shows
Separate the key discussion as "a different story from RLS," and a hole always opens. The two are one. Let me confirm this with a real incident.
CVE-2025-48757, registered in 2025, is the typical example. The NVD's official description is this — "Due to insufficient Row-Level Security policies of Lovable (until 2025-04-15), a remote unauthenticated attacker can read and write arbitrary DB tables of generated sites." Its classification is CWE-863 (Incorrect Authorization), and the CVSS base score is 9.3 CRITICAL.
This CVE is a case of "RLS was insufficient," but reinterpreted in this article's context, the implication is clear.
- A table with no / weak RLS can be touched by anyone with just the public key (anon). = Section 2's "the public key becomes a front-door key" accident.
- If even one
service_rolepath exists there, even with perfectly applied RLS, that path alone passes everything through. = Section 4's IDOR.
That is, "where you place the key" and "how you apply RLS" — getting only one right doesn't protect you. Since you distribute the public key, RLS is needed, and if you use service_role, you write ownership on the premise of no RLS. Keys and RLS are something you should always make design judgments about as a set.
What's notable is that this CVE is marked "disputed" by the vendor, with the platform side claiming "an app's data protection is the user's responsibility." Right or wrong aside, as a fact, key management and authorization are "your area of responsibility" that the platform doesn't take over — this CVE eloquently says so.
7. Key-operation checklist
Whether outsourced code or AI-written code, before shipping to production, confirm at minimum just this. So even a non-expert can judge, I summarize the viewpoint and the danger sign.
| Viewpoint | What to confirm | Danger sign |
|---|---|---|
| Where service_role is | Is the secret key not passed to the client/browser? | The service_role value is visible in front-end code, the network tab, or the JS bundle |
| NEXT_PUBLIC_ mis-attachment | Is NEXT_PUBLIC_ not attached to the secret key? | NEXT_PUBLIC_..._SERVICE_ROLE_KEY exists in .env |
| import boundary | Does the module touching the secret key have server-only? | An admin client is imported from "use client" |
| anon key and RLS | Is RLS enabled on all tables exposed via the public key? | There's a table without enable row level security |
| Ownership on the service_role path | Does an API using service_role have an ownership condition of user_id / tenant_id? | It receives an ID and returns with just .eq("id", ...) |
| Confirm the build artifact | Is the secret key's value not mixed into .next/static? | You've never once grepped the post-build static assets |
| Commit history | Is the secret key not left in git history? | There's a trace of committing .env / you haven't rotated the key |
| Rotation operation | Is there a procedure to swap the key on a leak? | You haven't decided "what to do if it leaks" |
From the orderer's viewpoint, the most effective are the 2 questions "where do you use the service_role key?" "what happens if I hit someone else's ID?" If they can't answer clearly, there's a high chance the design of keys and authorization isn't settled. A good developer can answer these immediately.
8. The range you do yourself, and the range you put to an audit (honestly)
Finally, let me honestly separate what can be automated and what can't. Because a "magic product" that makes this ambiguous is exactly what's dangerous.
What can be detected mechanically (automation takes effect):
- Secret-key mixing — mis-attachment to
NEXT_PUBLIC_, committed secrets, baking into the client bundle - RLS misconfiguration — RLS not enabled, the unconditional permission
using (true), a write policy missingWITH CHECK - The "suspicion" of missing ownership on a
service_rolepath — a data flow where tainted input reaches a query without an ownership scope
These can be picked up by shape (pattern), so it's rational to crush the misses with an OSS scanner. You can try it with no installation.
# インストール不要・設定不要でスキャン(秘密混入・RLS・所有権欠落の疑いを検出)
npx @aegiskit/cli scan
What can't be proven by a machine (human judgment is needed):
- The design judgment of which path to use
anonon, and which to useservice_roleon - "Who owns what," your business-specific data model and authorization rules
- Whether there's a legitimate reason to use
service_role, and the review intensity of that path
Honestly, no tool can prove that "your authorization is correct" or that "it's completely safe." What a tool looks at is the "shape" of the key or policy, not the "meaning" of your business rules. A clean result means "you didn't step on the common traps," not "it's safe." So position automated detection as something that complements human review and threat modeling, not replaces it.
If you want to step into design judgment or a key/authorization review of an existing app and bring in a third party's eyes, I handle it with the Aegis audit menu. By the way, this kind of design of "hardening keys, RLS, and ownership as one" is a domain I've actually operated in production systems involving tenants and billing, like the lumber-distribution DX B2B SaaS case study.
Frequently Asked Questions (FAQ)
Q. Is it OK to publish the anon key to GitHub?
A. If RLS is correctly taking effect on all tables, the anon key is a key meant to be public, so it's not fatal (Supabase: API Keys). But "RLS is taking effect" is an absolute condition. If even one table is missing RLS, that public key becomes a front door anyone can enter. Always judge a key's publishability as a set with RLS's state.
Q. I accidentally committed / published the service_role key.
A. Treat that key as already leaked. Just deleting it from git history is insufficient (assume someone has already obtained it). Immediately rotate it in the Supabase dashboard and invalidate the old key. On top of that, identify why it leaked (NEXT_PUBLIC_ mis-attachment / missing server-only boundary / committing .env) and put in a recurrence-prevention mechanism.
Q. Must I absolutely never use the service_role key?
A. No. There are legitimate uses: admin processing, batches, Webhooks, etc. The 2 iron rules — (1) use it only on the server side (absolutely never expose to the browser), (2) always enforce ownership in code. Don't use it on paths where you can't keep these 2. The principle is "lean on anon + RLS, and use service_role only on paths with a reason to bypass."
Q. If I apply RLS perfectly, can key management be sloppy?
A. No. service_role completely bypasses RLS (PostgreSQL: Row Security). Even FORCE ROW LEVEL SECURITY doesn't take effect on the BYPASSRLS role. Even with perfect RLS, leak one secret key and everything passes through. RLS and key management are two wheels, neither of which holds alone.
Q. Even for solo or small-scale, should I do this far?
A. Rather, the reality that CVE-2025-48757 and the like show is that apps built quickly with AI have more accidents. Even in a minimal configuration, be sure to do just the 3 points: "confine the secret key inside the server-only boundary" + "RLS on all tables exposed via the public key" + "ownership check on the service_role path." The cost is slight, and the size of the accidents you can prevent is orders of magnitude larger.
Summary: the defense boundary moves to "key placement" and "ownership placement"
Let me organize the key points.
- The
anonkey (publishable) is a key you may publish. But "only when RLS is taking effect on all tables." Without RLS, the public key becomes a front door anyone can enter. - The
service_rolekey (secret) runs with theBYPASSRLSprivilege and completely ignores RLS. It's an "instant death" key that exposes all tables if leaked. It leaks just by attachingNEXT_PUBLIC_/ importing it from the client. - You can't bind
service_roleon the DB side. EvenFORCE ROW LEVEL SECURITYdoesn't take effect onBYPASSRLS. What you can protect is only "key placement" — server-limited, theserver-onlyboundary, not attachingNEXT_PUBLIC_. - On paths using
service_role, because RLS doesn't take effect, the ownership check is 100% the code's responsibility. The one line.eq("user_id", user.id)becomes authorization's last bastion. - Design keys and RLS as one. As CVE-2025-48757 shows, getting only one right doesn't protect you, and the platform doesn't take it over.
- Honestly, what a tool can do is "detect" secret mixing and RLS misconfiguration. The design judgment of which key to use where, and who owns what, is the human's job.
Key handling is the one point in Supabase-app security where accidents are most frequent and most irreversible. Building fast with AI is itself correct. Hardening what you built fast safely, without leaking — if you need that key-operation and authorization design review, or an audit of an existing Next.js × Supabase app, feel free to consult us.
References
- Supabase Docs — API Keys (anon is publishable, RLS-premised / service_role is server-only)
- Supabase Docs — Row Level Security (service_role bypasses RLS)
- PostgreSQL Docs — Row Security Policies (BYPASSRLS / FORCE ROW LEVEL SECURITY)
- NVD — CVE-2025-48757 (Lovable / unauthenticated access via insufficient RLS, CWE-863, CVSS 9.3)
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization