# Correctly Handling the anon Key and service_role Key — The Key You May Publish, the Key That Means Instant Death if Exposed, and the RLS-Bypass Boundary

> Supabase's anon key is meant to be public, but the major premise is that RLS is taking effect. The service_role key completely ignores RLS with the BYPASSRLS privilege, and if leaked, all data is exposed. We explain the design of key placement, the server boundary, and ownership checks in real Next.js App Router code.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, Next.js, セキュリティ, TypeScript
- URL: https://tomodahinata.com/en/blog/supabase-anon-key-service-role-key-exposure-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- The anon key (publishable) is a public key distributed to the browser and 'may be exposed' — but only when RLS is taking effect on all tables. Without RLS, the public key becomes a front door anyone can enter
- The service_role key (secret) runs with PostgreSQL's BYPASSRLS privilege and completely ignores RLS. Leak = all tables exposed. It dies instantly just by attaching NEXT_PUBLIC_ / importing it from the client
- On paths using service_role, because RLS doesn't take effect, the ownership check is 100% the code's responsibility. The moment you forget the one line `.eq("user_id", user.id)`, someone else's data is returned
- The defense boundary moves to 'key placement (server-limited, the server-only boundary) + ownership checks.' Even FORCE ROW LEVEL SECURITY is leapt over by service_role
- Honestly, what a tool can do is 'detect' secret mixing and RLS misconfiguration. The design judgment of which key to use on which path, and who owns what, is the human's job

---

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](/blog/nextjs-supabase-application-security-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](https://supabase.com/docs/guides/api/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."

```bash
# 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](/blog/supabase-rls-misconfiguration-detection-audit-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](https://supabase.com/docs/guides/api/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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)).

Let me line up the two in actual client-generation code.

```ts
// (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（公開前提）
);
```

```ts
// (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 old `anon` equivalent), the latter server-only (the old `service_role` equivalent) ([Supabase: API Keys](https://supabase.com/docs/guides/api/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 names `anon` / `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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)).

- **Superuser / `BYPASSRLS` role** → always bypasses RLS (= `service_role` is here)
- **The table's owner** → normally bypasses RLS. But set `FORCE ROW LEVEL SECURITY` and you can apply RLS to the owner too
- **Other general roles** → RLS takes effect (= `anon` / `authenticated` are 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.

```sql
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.

```ts
// ❌ 事故その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に混入＝全データ露出
);
```

```tsx
// ❌ 事故その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.

```bash
# 流出した 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

```ts
// 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](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)). For an explanation narrowed to the discovery and fix of this flaw, see the [IDOR dedicated guide](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-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).

```ts
// 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:

```sql
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](https://supabase.com/docs/guides/database/postgres/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.**

```ts
// 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_id` but also `tenant_id` / `organization_id`. Forget the `tenant_id` narrowing on the `service_role` path, and all of the neighboring tenant's data leaks. The verification procedure for cross-tenant leaks is summarized in the [Multi-Tenant Verification Guide](/blog/supabase-multi-tenant-cross-tenant-leak-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 or `formData.get("id")` is equally client-manipulable. "It's safe because the ID isn't shown on screen" doesn't hold. If you use `service_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.

```bash
# .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.

```ts
// 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.

```bash
# ビルド後、クライアント静的アセットに秘密鍵の"値"が混入していないか確認
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](/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](https://nvd.nist.gov/vuln/detail/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_role` path 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 missing `WITH CHECK`
- The "suspicion" of missing ownership on a `service_role` path — 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.

```bash
# インストール不要・設定不要でスキャン（秘密混入・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 `anon` on, and which to use `service_role` on
- "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](/aegis/audit). 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](/case-studies/lumber-industry-dx).

---

## 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](https://supabase.com/docs/guides/api/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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). 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 `anon` key (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_role` key (secret) **runs with the `BYPASSRLS` privilege and completely ignores RLS.** It's an "instant death" key that exposes all tables if leaked. It leaks just by attaching `NEXT_PUBLIC_` / importing it from the client.
- **You can't bind `service_role` on the DB side.** Even `FORCE ROW LEVEL SECURITY` doesn't take effect on `BYPASSRLS`. What you can protect is only "key placement" — server-limited, the `server-only` boundary, not attaching `NEXT_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)](https://supabase.com/docs/guides/api/api-keys)
- [Supabase Docs — Row Level Security (service_role bypasses RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL Docs — Row Security Policies (BYPASSRLS / FORCE ROW LEVEL SECURITY)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [NVD — CVE-2025-48757 (Lovable / unauthenticated access via insufficient RLS, CWE-863, CVSS 9.3)](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
