# Next.js × Supabase Application Security Complete Guide — Protecting Authorization and RLS with Vulnerability Detection and Defense in Depth

> The overall picture of security for AI-mass-produced Next.js × Supabase apps. We divide it into automatable horizontal controls (CSP, rate limiting, CSRF, Zod validation), injection detected by static analysis (SQLi/SSRF/XSS), and vertical risks only design can close (authorization/IDOR, RLS, tenant isolation), and systematize how to protect with 3 detection layers and defense in depth.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Next.js, Supabase, RLS, セキュリティ, TypeScript, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide
- Category: Application-layer security

## Key points

- App-layer security splits into 2 kinds — 'horizontal controls' you can close uniformly with libraries/config (CSP, rate limiting, CSRF, Zod validation, secret hygiene), and 'vertical risks' only a human who knows the data model can design (authorization/IDOR, RLS, tenant isolation, business logic)
- AI-generated code 'works but isn't safe.' In Veracode's measurement, 45% contained known vulnerabilities, and scores stayed flat even as models got smarter. CVE-2025-48757 (CVSS 9.3) is a real case where RLS gaps allowed unauthenticated access
- The injection classes (SQLi, SSRF, path traversal, open redirect, DOM XSS) can be detected mechanically with static analysis (taint) that follows the data flow of 'tainted input → dangerous sink'
- Authorization/IDOR can't be prevented by a WAF or headers. Because the attack is a 'legitimate request' with correct authentication and format, and only your business logic decides whether it's an attack. Correlate detection across the 3 layers of SAST/RLS verification/DAST
- What can be automated is the implementation of horizontal controls and the 'detection and warning' of vertical risks. The design fix of authorization and RLS is a human design judgment = the audit domain, and no tool proves the 'correctness' of authorization

---

Let me state the conclusion first. **Next.js × Supabase application security becomes far clearer at once if you draw the map by dividing it into "automatable horizontal controls" and "vertical risks only design can protect."** The former is security headers, CSP, rate limiting, CSRF, input validation, and secret hygiene, closeable **uniformly** with libraries and config. The latter is authorization (IDOR), RLS design, tenant isolation, and business logic, which **only a human who knows your data model can design.**

This distinction matters because the world's notion of "put in a security product and you're safe" **only works for horizontal controls.** Vertical risks appear as a "legitimate request" with perfectly correct authentication and format, so a WAF or headers structurally can't prevent them. This article, after redrawing the boundary between the two, systematizes — based on real code and published primary sources — what to crush with automation and what to protect with design and verification. This isn't a "don't use AI" or "Supabase is dangerous" story. **Building fast with AI is itself correct. It's a story about the mechanism to harden what you built fast safely, without leaking.**

---

## 1. The overall picture: the 3-layer map of horizontal controls, injection classes, and vertical risks

Before getting into the details, let me hand you the map. App-layer threats split into 3 by nature and "how far automation reaches."

| Layer | Representative threats | Main way to prevent | Where automation reaches |
|---|---|---|---|
| **Horizontal controls** | Missing headers, CSP gaps, no rate limiting, CSRF, secret leakage, env gaps | Apply uniformly with libraries, config, middleware | Can automate **through implementation** |
| **Injection classes** | SQLi, SSRF, path traversal, open redirect, DOM XSS | Input validation/neutralization + safe APIs | Can **detect with static analysis (taint)** |
| **Vertical risks** | Authorization/IDOR, RLS misconfiguration, tenant crossing, business logic, privilege escalation | Secure design + verification (RLS + ownership) | **Through detection and warning.** The fix = design is human |

The upper 2 layers (horizontal controls, injection classes) aren't things a human should think about every time. The correct answer is to **fix them once with config and have static analysis stand guard mechanically.** The problem is the 3rd layer. Because vertical risks depend on the app-specific meaning of "who owns what," a tool can point out "suspicious" but can't say "correct."

The discipline running through this entire article is the same philosophy OWASP's **[Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/)** shows — measure security not by "did you put it in" but by "**can you verify it.**" Hereafter, for each layer, I state "how to close it" and "how to verify it" as a set.

---

## 2. The threat premise: the holes that AI mass-produces in Next.js × Supabase

Why is this map needed now? Regarding the security of AI-generated code, there's measurement, not speculation. In Veracode's 2025 study, which set 100+ LLMs 80 coding tasks across 4 languages, **45% of the generated code contained known security flaws, and only 55% were safe.** What's more important is that **even as models got significantly bigger and smarter, the security scores stayed flat** ([Veracode 2025 GenAI Code Security Report](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)). Code has become "more working," but not "more secure."

The reason is simple. AI realizes "what you want to do (= what works in a demo)" instructed by the prompt via the shortest distance. "Don't show other people's data" and "don't let it hit internal resources" lie outside the happy path unless explicitly requested, and **absolutely don't surface in a demo.** Because as long as you're touching it with your own account, no one rewrites an ID or puts an internal-metadata address in `url=`.

This isn't a desk-bound worry. **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)**, registered in 2025, is one where, due to **insufficient Row-Level Security policies** of an AI-generation platform (Lovable, until 2025-04-15), a remote **unauthenticated** attacker could 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.** A real case where a vertical-risk flaw came to production in the most ordinary and most serious form.

> **"It worked" ≠ "it's safe."** This is the starting point of this article. On hardening AI-generated code before shipping to production, I summarize separately in [Production Hardening of AI-Generated Code](/blog/vibe-coding-ai-generated-code-production-hardening-guide). This article is the map narrowed to its "app layer."

---

## 3. Horizontal controls (the automatable layer) — fix it once with config

The first thing to crush is horizontal controls. They apply uniformly across the app, and because **libraries and config can take them over**, they're overwhelmingly cost-effective. The trick is to not assume "a human writes it correctly every time."

### 3-1. Typed env boundary — validate secrets at the boundary, prevent mixing

Environment variables are "external input." Validate them once at startup, and use them type-safely thereafter. With `server-only`, physically prevent secrets from mixing into the client bundle.

```ts
// lib/env.server.ts — サーバー専用。クライアントに混入したらビルド時に弾く
import "server-only";
import { z } from "zod";

export const serverEnv = z
  .object({
    SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
    RESEND_API_KEY: z.string().startsWith("re_"),
  })
  .parse(process.env); // 起動時に1度。欠けていれば即クラッシュ＝fail-fast
```

Whether the `NEXT_PUBLIC_` prefix is present is critical. **Expose the service_role key with `NEXT_PUBLIC_` and all data leaks.** The separation of responsibilities of the anon key and the service_role key is itself a large subject, so it's detailed in [Handling the anon Key and service_role Key](/blog/supabase-anon-key-service-role-key-exposure-guide). The nature of the keys is at the primary source [Supabase: API keys](https://supabase.com/docs/guides/api/api-keys).

### 3-2. Input validation (Zod) — narrow the type at the system boundary

Structurally validate all external input at the handler's entrance. Don't trust "the form it came in as."

```ts
const Body = z.object({
  email: z.string().email(),
  phase: z.enum(["discovery", "build", "audit"]),
});

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json());
  if (!parsed.success) return Response.json({ error: "invalid" }, { status: 400 });
  // 以後 parsed.data は型安全
}
```

### 3-3. Security headers / CSP (nonce) — shrink the damage of XSS

CSP is the last wall of "even if XSS mixes in, don't let it execute an external script." Avoid `'unsafe-inline'` and **issue a nonce per request** is the strict version.

```ts
// middleware.ts — リクエストごとに nonce を発行し、厳格な CSP を付与する
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self'`,
    `img-src 'self' data:`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `frame-ancestors 'none'`, // クリックジャッキング対策
  ].join("; ");

  const headers = new Headers(request.headers);
  headers.set("x-nonce", nonce); // Server Component から読めるよう引き回す

  const res = NextResponse.next({ request: { headers } });
  res.headers.set("Content-Security-Policy", csp);
  return res;
}
```

Together, attach `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy`. These are typical horizontal controls that **take effect on all requests once written.**

### 3-4. Rate limiting — in serverless, put the state in a "shared store"

This is where AI-generated code most often goes wrong. Because serverless functions are **disposed of per request**, **an in-process `Map` counter doesn't work** (if executed on a different instance, the count resets). Put the state in an external shared store.

```ts
// レート制限：状態はプロセス外（Redis 等）へ。メモリ内カウンタはサーバーレスで無効
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "60 s"),
});

export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
  const { success } = await ratelimit.limit(ip);
  if (!success) return new Response("Too Many Requests", { status: 429 });
  // 本処理…
}
```

### 3-5. CSRF / Origin verification — confirm the origin of state changes

State-change paths like Server Actions or `POST` routes are protected in two stages, **verifying the Origin** in addition to a `SameSite` Cookie.

```ts
// 状態変更リクエストは Origin を検証する
const origin = req.headers.get("origin");
const allowed = new URL(process.env.NEXT_PUBLIC_SITE_URL!).origin;
if (req.method !== "GET" && origin !== allowed) {
  return new Response("Forbidden", { status: 403 });
}
```

This far is horizontal controls. **All of them can be taken over just by "choosing the right library/config," requiring no app-specific knowledge.** That's exactly why this is the domain to have CI stand guard, not the human brain.

---

## 4. Injection classes (the layer detectable with static analysis) — follow tainted input

Injection occurs when "**input the client can manipulate (tainted input / source) reaches dangerous processing (a sink) without being validated.**" Because it has a common structure, you can follow it mechanically not with regular expressions but with **data-flow analysis (taint analysis).**

| Tainted input (source) | Dangerous sink (sink) | Vulnerability class |
|---|---|---|
| `searchParams.get("q")` | String-concatenated SQL / `rpc()` | SQL injection |
| `searchParams.get("url")` | `fetch(url)` | SSRF |
| `params.file` | `fs.readFile(path)` | Path traversal |
| `searchParams.get("next")` | `redirect(next)` | Open redirect |
| A request-derived string | `dangerouslySetInnerHTML` | DOM / stored XSS |

### SQL injection: don't assemble a string, pass it as a value

Supabase's structured API (`.eq()`, etc.) treats values as parameters and is basically safe, but **`.or()` that receives a raw filter string, and string-concatenated SQL inside `rpc()`** become injection paths.

```ts
// 脆弱：ユーザー入力を .or() のフィルタ文字列に直接連結（PostgRESTフィルタ injection）
const q = new URL(req.url).searchParams.get("q") ?? ""; // ← 汚染入力
const { data } = await supabase
  .from("posts")
  .select("*")
  .or(`title.ilike.%${q}%`); // q に区切り文字を仕込むと条件を崩し、意図しない行を引ける

// 修正：構造化APIで「値」として渡す（フィルタ文字列を組み立てない）
const { data: safe } = await supabase
  .from("posts")
  .select("*")
  .ilike("title", `%${q}%`); // q はパラメータ扱いになる
```

Building dynamic SQL like `EXECUTE 'select ... ' || input` inside a `SECURITY DEFINER` function is the same hole. The iron rule is to use `format(..., %L)` or parameter binding, and not string-concatenate input.

### SSRF: the server reaches a user-specified URL

```ts
// 脆弱：汚染入力がそのまま fetch のシンクに届く（SSRF）
export async function GET(req: Request) {
  const url = new URL(req.url).searchParams.get("url")!; // ← 汚染入力
  const res = await fetch(url); // ← 危険シンク：169.254.169.254 等の内部資源に到達しうる
  return new Response(await res.text());
}
```

The fix is "**an allowlist of permitted hosts + blocking private/link-local IP ranges + disabling redirect following.**" In Supabase's server environment, reaching internal metadata or other services is fatal.

### Open redirect: trusting the return destination as-is

```ts
// 脆弱：next をそのまま信じてフィッシングへ誘導される
const next = new URL(req.url).searchParams.get("next") ?? "/";
redirect(next); // next="https://evil.example/login" でも飛ぶ

// 修正：相対パスだけ許可する（"//" 始まりはプロトコル相対なので除外）
redirect(next.startsWith("/") && !next.startsWith("//") ? next : "/");
```

### Path traversal: escaping the file tree with `../`

Concatenate user input into a path and read a file, and `../../` reaches outside the intended directory (`.env` or key files).

```ts
// 脆弱：汚染入力をそのままパスに連結（パストラバーサル）
const name = new URL(req.url).searchParams.get("file")!; // ← "../../.env" など
const buf = await fs.readFile(path.join("./uploads", name)); // 危険シンク

// 修正：basename で剥がし、解決後のパスが基底配下にあることを検証する
const base = path.resolve("./uploads");
const resolved = path.resolve(base, path.basename(name));
if (!resolved.startsWith(base + path.sep)) throw new Error("invalid path");
const safe = await fs.readFile(resolved);
```

DOM XSS is the same type. Pass a request-derived value to `dangerouslySetInnerHTML` and it executes (it's a sink to limit to trusted output, like server-computed JSON-LD).

The good thing about injection classes is that **if you follow the path from the tainted source to the dangerous sink, you can detect it without the app's business knowledge.** Unlike the next section's vertical risks, here a tool shows its true ability.

---

## 5. Vertical risks (the layer only design can close) — RLS and authorization

This is the core of this article. Because vertical risks depend on the app-specific meaning of "who owns what," **neither a library, nor config, nor a WAF can take them over.**

### 5-1. Why a WAF or headers can't prevent them

Let me take IDOR (OWASP **[API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)**) as an example. If just rewriting `GET /api/invoices/1024` to `/api/invoices/1025` returns someone else's invoice, that's IDOR. BOLA has been **#1** in the [OWASP API Security Top 10](https://owasp.org/API-Security/editions/2023/en/0x11-t10/) ever since the first edition — the most frequent risk.

This request is **perfectly normal as HTTP.** The auth header is correct, and it contains no malicious string. From a WAF's viewpoint, it's a "legitimate request from a legitimate user." It becomes an attack because of **the app-specific business fact that "#1025 is not owned by this user"** — and the WAF doesn't know your data model. The mechanism by which IDOR arises and the fix are dug into in the [IDOR / Broken-Authorization Detection Guide](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide).

### 5-2. RLS misconfiguration — "put it on" and "it's taking effect" are different

Supabase can enforce authorization from the data layer with PostgreSQL's row-level security (RLS) ([Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) / [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). It's powerful, but **"enabled it" and "taking effect correctly" are different things.** Let me list common misconfigurations.

```sql
-- アンチパターン集
using ( true )                              -- 無条件許可＝実質RLSなし
-- WITH CHECK の無い書き込みポリシー        -- INSERT/UPDATE で他人の行を作れる
-- SECURITY DEFINER 関数で search_path 未固定 -- 権限昇格の温床
-- anon ロールへの過剰な GRANT              -- 公開鍵で書き込めてしまう
```

The systematic detection of these is summarized in [Detecting and Auditing RLS Misconfigurations](/blog/supabase-rls-misconfiguration-detection-audit-guide), and robust policy design in production multi-tenant in [Production RLS Multi-Tenancy Design](/blog/supabase-rls-production-multi-tenancy-patterns).

### 5-3. Tenant isolation — get the "value you base it on" wrong and the whole company leaks

The most frightening is the case where the policy is syntactically correct but **the value it bases on is user-manipulable.** Supabase's JWT has `user_metadata` (the user themselves can update) and `app_metadata` (only the server side can write).

```sql
-- 危険：user_metadata はユーザー自身が書き換えられる（クライアント操作可能）
create policy "tenant read"
on documents for select
to authenticated
using ( tenant_id = (auth.jwt() -> 'user_metadata' ->> 'tenant_id')::uuid );
-- → 攻撃者が自分の user_metadata.tenant_id を他社IDに書き換えれば、他テナントの行が見える

-- 修正：サーバーだけが書ける app_metadata を根拠にする
create policy "tenant read"
on documents for select
to authenticated
using ( tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid );
```

This difference is hard to see through with a tool's syntax check, and is the **design judgment itself of "what decides tenant attribution in this system."** The verification procedure for cross-tenant leaks is carved out into [Verifying Cross-Tenant Leaks in Multi-Tenant](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide).

### 5-4. service_role "leaps over" RLS

Finally, even if RLS is perfect, there's a path that disables it all. **The service_role key runs with PostgreSQL's `BYPASSRLS` privilege and completely ignores RLS.** Use service_role server-side "because it reliably works" and forget to write the ownership check (`.eq("user_id", user.id)`), and IDOR opens regardless of whether RLS exists. The defense boundary moves from "is there RLS" to **"where you place service_role and where you enforce ownership."**

```ts
// 脆弱：service_role でRLSを飛び越え、所有権チェックを忘れている（IDOR）
const supabaseAdmin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!);

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params; // ← クライアントが自由に変えられる
  const { data } = await supabaseAdmin.from("invoices").select("*").eq("id", id).single();
  return Response.json(data); // RLSが完璧でも、service_role が飛び越えるので他人の行が返る
}

// 修正：service_role を使うなら、誰なのかを確定し所有権を必ず WHERE で縛る
const user = await getAuthenticatedUser(req);
if (!user) return new Response("unauthorized", { status: 401 });
const { data } = await supabaseAdmin
  .from("invoices").select("*")
  .eq("id", id).eq("user_id", user.id).single(); // ← この1行が無いとIDOR
```

The principle is "**don't use service_role in the first place; run with the user's session (the anon key) and leave authorization to RLS.**" Only on paths with a reason to bypass, enforce ownership as above and watch them intensively in review (details in the IDOR guide above).

### 5-5. Business-logic flaws — allowing "impossible states"

The last of the vertical risks is the class where an authorized legitimate user **does something illegitimate with business-impossible input.** This can't be prevented by RLS or headers, and only a human with domain knowledge can define it.

```ts
// 例：クライアントから送られた価格・数量をそのまま信じる
const { price, qty } = await req.json();
const total = price * qty; // price=0、qty=-1、過去の価格…をそのまま受理してしまう
```

"Recalculate the amount on the server," "constrain the quantity to a positive integer," "force the order of state transitions (draft → billed → paid)" — such rules are **the spec, and can't be derived from the code's external form.** That's exactly why it's the domain where automation's limits show most sharply.

---

## 6. The 3 detection layers — correlate SAST / RLS verification / DAST

Once you've decided to close it with design, verify "is it closed." Don't rely on one means; layer static, structural, and dynamic.

### Layer 1: Static analysis (SAST) — follow the code's data flow

With intra-function taint analysis, follow whether tainted input reaches a DB query or dangerous sink "without being narrowed by ownership." It's a detection you can't write with regular expressions. My published OSS Aegis implements this and runs with no installation.

```bash
# インストール不要・設定不要でスキャン（汚染入力→危険シンクを可視化）
npx @aegiskit/cli scan
```

### Layer 2: SQL / RLS verification — does authorization live correctly "in the DB"

Separately from the code, read `supabase/migrations/**.sql` and sweep out RLS-disabled tables, `using (true)`, missing `WITH CHECK`, `SECURITY DEFINER` without a fixed `search_path`, and over-GRANTs to anon. Further, **cross-check SQL and code**, and point out "the place where a table with weak RLS is actually queried from a non-admin client" as a confirmed exposure.

### Layer 3: Dynamic confirmation (DAST) — actually hit it "as someone else"

Static analysis goes as far as "suspecting." Finally, reproduce IDOR / tenant crossing **on an app you own** to **confirm** it. Prepare 2 identities (A and B), and hit B's resource while staying in A's session — if 200 returns, it's confirmed at runtime.

```bash
# 自分のアプリへの安全・非破壊なプローブ（所有権の食い違いを実行時に確認）
npx @aegiskit/cli probe http://localhost:3000 --correlate
```

What matches between the static "suspicion" and the dynamic "reproduction" is fixed first as **confirmed-exploitable** — this is the SAST↔DAST correlation. To prevent regression, write [RLS regression tests with pgTAP](/blog/supabase-rls-testing-pgtap-policy-regression-guide) and continuously prove "other people's rows aren't visible" in CI.

```sql
-- pgTAP：別ユーザーのJWTで他人の行が見えないことを回帰テストにする
begin;
select plan(1);
set local role authenticated;
set local request.jwt.claims to '{"sub":"user-b-uuid"}';
select is_empty(
  $$ select * from invoices where user_id = 'user-a-uuid' $$,
  'user B cannot read user A invoices'
);
select * from finish();
rollback;
```

> **The honest scope.** No static or dynamic tool **proves your authorization is correct.** What it looks at is the "shape" of the policy or implementation, not the "meaning" of the business rules or data model. Data-flow analysis is intra-function (intraprocedural) by default and misses flows spanning modules or the framework. A clean result means "you didn't step on the common traps," not "it's safe." **These verifications don't replace human review and threat modeling; they complement them.**

---

## 7. The defense-in-depth map — which layer, who protects

Let me bundle the above onto one page. What matters is that **the deeper the layer, the more "the protecting subject" moves from the platform to the human.**

| Layer | Main threats | Countermeasure | Who protects |
|---|---|---|---|
| Network / edge | DDoS, bots, known malicious IPs | WAF, rate limiting, bot countermeasures | Platform / config |
| HTTP / browser | XSS, clickjacking, MIME sniffing | CSP (nonce), various security headers | Library / middleware |
| Input boundary | SQLi, SSRF, path traversal, malformed data | Zod validation, safe APIs, allowlist | Developer + static analysis |
| Authentication | Impersonation, session fixation | Supabase Auth, SameSite Cookie | Library + config |
| **Authorization (data)** | **IDOR/BOLA, tenant crossing** | **RLS + code ownership checks** | **Human design** |
| Data layer | RLS bypass, key leakage | anon-key operation, service_role isolation, pgTAP | Human design + verification |

The upper half (network ~ authentication) is horizontal controls and injection classes, fixed uniformly with config and libraries. **The lower half (authorization, data layer) is vertical risks**, protectable only with the two wheels of design and verification. The reason "you're safe because you put in a product" doesn't hold is that the most serious risk is in the place hardest to automate.

---

## 8. Pre-production checklist

Whether outsourced or AI-made, before shipping to production, confirm at minimum just this. I list the viewpoint and the danger sign together.

- [ ] **RLS enabled on all tables**, with policies explicit (just enabling defaults to deny-all = fail-secure)
- [ ] **The service_role key is server-only.** Prevent mixing with `server-only`, and don't expose it with `NEXT_PUBLIC_`
- [ ] Paths using service_role **always bind ownership with `WHERE`** (`.eq("user_id", user.id)`)
- [ ] **Zod-validate env at startup**, and don't put secrets in the client bundle
- [ ] State-change APIs have **Origin verification + rate limiting** (shared store)
- [ ] Attach **CSP (nonce)** and the main security headers
- [ ] **Tainted input doesn't pass straight through** to injection sinks (`fetch`/`redirect`/`fs`/raw SQL)
- [ ] Tenant isolation bases on a **server-privilege value like `app_metadata`** (not `user_metadata`)
- [ ] **Confirmed ID swapping / tenant crossing with 2 accounts** at runtime
- [ ] **SAST / RLS verification / regression tests (pgTAP)** permanently installed in CI

From the orderer's viewpoint, the most effective are the 3 questions **"what happens if I hit someone else's ID?" "where do you use the service_role key?" "what do you base tenant attribution on?"** A good developer can answer immediately.

---

## 9. How far yourself, from where an audit

Finally, let me draw the line honestly.

**Horizontal controls (Section 3) and injection classes (Section 4) can be crushed mechanically, for the most part, with automation.** Choose the right library and config, put static analysis in CI, and a human doesn't need to think every time. First, visualizing the current state with [Aegis](/aegis) (free OSS, `npx @aegiskit/cli scan`) is the most cost-effective first step.

On the other hand, **even though you can automate the "detection and warning" of vertical risks (Section 5), the "fix = design" is the human domain.** The correctness of authorization, the grounds for tenant attribution, and the validity of business logic can only be judged by a human who understands your data model and business rules. A product that asserts "it's safe" here is rather dangerous. **Aegis helps the implementation of horizontal controls and detects/warns on vertical risks, but it doesn't prove that authorization is correct. There's no magic that makes you completely safe.**

That's exactly why a line is needed. **How far to fix yourself, and from where an expert's review is needed** — I organized those criteria in [The Scope Where a Security Audit Becomes Necessary](/blog/nextjs-supabase-security-audit-scope-when-needed-guide). If you need a design fix of vertical risks, or an authorization/RLS review of an existing app, I undertake it with a [security audit](/aegis/audit). I myself have designed and verified app-layer authorization, including RLS, tenant isolation, and ownership enforcement, in real operation on the [lumber-distribution-industry DX project](/case-studies/lumber-industry-dx).

---

## Frequently Asked Questions (FAQ)

**Q. What should I tackle first?**
A. There's an order. (1) Enable RLS on all tables and isolate service_role (the vertical foundation), (2) visualize injection and horizontal-control holes with `npx @aegiskit/cli scan`, (3) confirm ID swapping with 2 accounts. These 3 prevent the biggest accidents at the minimum cost.

**Q. Is it enough to put in a WAF and security headers?**
A. Not enough. As in Section 7, those only take effect on the upper half (horizontal controls), and the most serious authorization (IDOR, tenant crossing) is a "legitimate request" so they can't prevent it. Both are needed, but one doesn't substitute for the other.

**Q. Will it be fixed if I ask AI to "write it securely"?**
A. Don't over-expect. In Veracode's study, security scores stayed flat even as models got smarter. AI is strong at generating "code that works," but doesn't guarantee a "structure that doesn't break." Only by passing it through verification gates (scan, test, review) does it become production quality.

**Q. Is authorization safe just by putting on RLS?**
A. No. The service_role path leaps over RLS, and domains where it doesn't take effect remain — `SECURITY DEFINER` functions, RPC, Storage, external join targets, etc. Furthermore, get the value you base on wrong (`user_metadata` or `app_metadata`), and even syntactically correct RLS leaks the whole company. RLS is the mandatory "last bastion," but the correct answer is to protect in two layers with code-side ownership checks.

**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 exposure cases. At minimum, be sure to do just the 3 points: "RLS on all tables," "service_role isolation + ownership checks," and "ID-swapping confirmation with 2 accounts." The cost is slight, and the accidents you can prevent are orders of magnitude larger.

---

## Summary: with a map, the priority of defense is decided

Let me organize the key points.

- Draw the map of app-layer security by dividing it into the 3 layers **horizontal controls, injection classes, and vertical risks.** Crush the upper 2 with automation, and protect the lowest with design and verification.
- **Horizontal controls** (headers/CSP, rate limiting, CSRF, Zod, env, secret hygiene) can be **taken over uniformly** with the right library and config. The crux of serverless rate limiting is putting the state in a shared store.
- **Injection classes** (SQLi, SSRF, path traversal, open redirect, DOM XSS) can be **detected mechanically with static analysis (taint)** that follows the data flow of "tainted input → dangerous sink."
- **Vertical risks** (authorization/IDOR, RLS design, tenant isolation, business logic) appear as a "legitimate request" with correct authentication and format, so **a WAF or headers can't prevent them.** The defense boundary moves from "whether RLS exists" to "where you place the key and ownership" and "the grounds for tenant attribution."
- Correlate detection across the 3 layers of **SAST / RLS verification / DAST.** But **a tool only helps discovery, and the correctness of authorization can only be protected by design and human review.**

Building fast with AI is itself correct. **Hardening what you built fast safely, without leaking** — if you need that mechanism-building, or an authorization/RLS review of an existing Next.js × Supabase app, feel free to consult us.

---

## References

- [OWASP API Security Top 10 (2023)](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)
- [OWASP API1:2023 — Broken Object Level Authorization (BOLA/IDOR, the most frequent API risk)](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
- [OWASP Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/)
- [NVD — CVE-2025-48757 (unauthenticated access via insufficient RLS, CWE-863, CVSS 9.3)](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [Veracode — 2025 GenAI Code Security Report (45% of AI-generated code has security flaws)](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)
- [Supabase Docs — Row Level Security (service_role bypasses RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [Supabase Docs — API keys (the difference between anon and service_role)](https://supabase.com/docs/guides/api/api-keys)
- [PostgreSQL — Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
