# The 'write bypass' caused by a missing WITH CHECK in Supabase RLS — the difference from USING, and correctly protecting INSERT/UPDATE

> Organizing the easily-confused difference between USING (the read filter) and WITH CHECK (the write filter) in Supabase RLS, this explains, with vulnerable→fixed SQL for INSERT/UPDATE/ALL, the 'write bypass' where a write policy lacking WITH CHECK lets an authenticated user create rows with someone else's user_id or another tenant's id.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, PostgreSQL, セキュリティ
- URL: https://tomodahinata.com/en/blog/supabase-rls-with-check-using-write-bypass-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- USING is the read filter that decides 'which existing rows are visible'; WITH CHECK is the write filter that decides 'which new rows can be written.' SELECT/DELETE use only USING, INSERT only WITH CHECK, and UPDATE both.
- A write policy with no WITH CHECK (or set to with check (true)) opens a 'write bypass' — an authenticated user can insert another tenant's id with INSERT and rewrite a row's user_id to someone else with UPDATE.
- In UPDATE/ALL, when WITH CHECK is omitted, USING is reused for the new row too (fallback). But there's no fallback for INSERT, the protection disappears the moment you write with check (true), and the escalation of columns USING doesn't pin (role, tenant_id, etc.) can't be prevented.
- It's mass-produced by AI generation and short deadlines. The shortest way to silence the INSERT-time 'new row violates row-level security policy' error is with check (true), and that becomes the hole as-is. CVE-2025-48757 is a real example where insufficient RLS allowed unauthenticated access.
- Honestly, static verification of migrations and npx @aegiskit/cli scan can detect the 'form' of 'WITH CHECK missing/true/mismatched with USING,' but whether that predicate is the correct ownership/tenant condition is a human design judgment, and a tool doesn't prove the correctness of authorization.

---

Let me state the conclusion first. **In Supabase's (PostgreSQL) row-level security (RLS), the most accident-prone thing is confusing `USING` and `WITH CHECK`. `USING` is the read filter that decides "which 'existing rows' are visible," and `WITH CHECK` is the write filter that decides "which 'new rows' may be written" — the two are completely different gates.** A policy that carefully writes only the read side (`USING`) and lacks the write side (`WITH CHECK`) — or silences it with `with check (true)` — opens a **'write bypass' where an authenticated legitimate user can create "a row with someone else's `user_id`" or "a row with another tenant's `id` inserted."**

This is a counterintuitive, twisted accident. A hole of "being able to *write* someone else's row" coexists in a table where "you can only *read* your own row." The read test passes, it doesn't surface in a demo, and it's revealed only in production — or only to the attacker. This article accurately pins down the difference between `USING` and `WITH CHECK` per command, decomposes the vulnerable SQL of INSERT/UPDATE/ALL together with the actual attack, and explains the fix SQL and the procedure of detection/verification, based on PostgreSQL/Supabase primary sources. This deep-dives, **narrowed to the write side,** the layer I called "the vertical risk (authorization that can only be protected by design)" in the [Next.js × Supabase application-security complete guide](/blog/nextjs-supabase-application-security-guide).

---

## 1. Decompose the conclusion: USING and WITH CHECK are different gates

An RLS policy has two places to write a conditional expression: `USING` and `WITH CHECK`. Their roles are clearly divided ([PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)).

- **`USING` (inspection of existing rows)** — evaluated against rows that already exist in the table, and only rows that became `true` can be "seen / made an operation target." This is the **read-side filter.**
- **`WITH CHECK` (inspection of new rows)** — evaluated against the **content of the new row** that arises from `INSERT` or `UPDATE`, and rejected with an error if it becomes `false` (or null). This is the **write-side filter.** The official clearly states — "the `check_expression` is evaluated against the *proposed new contents* of the row, not the *original contents*."

In other words, `USING` is "which rows to show," and `WITH CHECK` is "what kind of row to let write." **Binding the read doesn't bind the write.** This one point is everything in this article.

Let me table which works in which command. This corresponds to "Policies Applied by Command Type" in [PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html).

| Command | USING (inspection of existing rows) | WITH CHECK (inspection of new rows) |
|---|---|---|
| SELECT | works (narrows visible rows) | can't be specified |
| INSERT | can't be specified (no existing rows) | works (inspects the row to create) |
| UPDATE | works (narrows the existing rows that can be updated) | works (inspects the row after update) |
| DELETE | works (narrows the existing rows that can be deleted) | can't be specified |
| ALL | works | works |

The three important facts that can be read from this are:

1. **`SELECT` and `DELETE` are `USING` only.** Reading and deleting are about "existing rows," so the inspection of new rows (`WITH CHECK`) has no turn.
2. **`INSERT` is `WITH CHECK` only.** Since there are no existing rows, `USING` **can't even be specified** (official: "an `INSERT` policy can't have a `USING` expression"). That is, **the safety of insertion hangs on `WITH CHECK` alone.**
3. **`UPDATE` works for both.** Bind "which existing rows can be updated" with `USING`, and "what to allow in the row after update" with `WITH CHECK`, in two stages.

The positioning of RLS itself has [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) and [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) as primary sources. "Having enabled RLS" and "both read and write working correctly" are different things — this article handles the latter half, **the verification of the write side.**

---

## 2. The "safety net with a pitfall" of the UPDATE/ALL fallback

Here, let me accurately pin down an important behavior many explanations omit. **In an `UPDATE` and `ALL` policy, if you omit `WITH CHECK`, PostgreSQL reuses the `USING` expression for `WITH CHECK` too.** In the official words — "for policies that can have both `USING` and `WITH CHECK` (`ALL` and `UPDATE`), if `WITH CHECK` is not defined, the `USING` expression is used for **both** the purpose of deciding visible rows (the normal `USING`) and the purpose of deciding the new rows allowed to be added (`WITH CHECK`)" ([PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)).

The manager example of [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) is the same gist — "since this policy implicitly provides a `WITH CHECK` clause identical to `USING`, the constraint applies to both *selected rows* and *modified rows* (= you can't `INSERT`/`UPDATE` to create another manager's row)."

This is, at first glance, a welcome safety net, but **you must not rely on it for three reasons.**

- **There's no fallback for `INSERT`.** Since an `INSERT` policy can't have `USING`, there's no source to reuse in the first place. Insertion has no choice but to **write `WITH CHECK` yourself.**
- **It disappears the moment you write `with check (true)`.** Since reuse happens only when "omitted," if you explicitly write `with check (true)`, the fallback doesn't work, and the new row becomes **uninspected** (the most-frequent accident described later).
- **Columns USING doesn't pin aren't protected.** The fallback just reuses the `USING` expression as-is. `using (user_id = auth.uid())` binds the new row's `user_id`, but lets **the rewrite of a different column** like `role` or `tenant_id` pass straight through (detailed in section 5).

Organized, the patterns where "the read is protected but the write is defenseless" are as follows.

| How the policy is written | Read (SELECT) | Write (INSERT/UPDATE) | Result |
|---|---|---|---|
| Only FOR SELECT USING(ownership) | protected | no write policy = deny all by default | can't write (fail-secure, not a bug) |
| FOR INSERT WITH CHECK(true) | depends on a separate SELECT | doesn't inspect the new row | can insert another tenant's id = bypass |
| FOR UPDATE USING(ownership) WITH CHECK(true) | depends on a separate SELECT | doesn't inspect the row after update | can rewrite a row's user_id = bypass |
| FOR ALL USING(ownership) (WITH CHECK omitted) | protected | reuses USING by fallback | the ownership column is protected (but escalation of other columns is a separate problem) |

What I want you to note is the first row. **"No write policy" isn't a bypass.** If you enable RLS and there isn't a single policy, the default is "deny all (fail-secure)." Writes are silently rejected. The problem occurs when you **carelessly release** that "rejected" state — to the next section.

---

## 3. Why the write side becomes defenseless: AI mass production and the temptation to "silence the error"

Why is this hole mass-produced to this degree? There are two reasons.

### 3-1. The shortest way to silence "new row violates row-level security policy"

When you `INSERT` to an RLS-enabled table, if there's no write policy (or it doesn't satisfy `WITH CHECK`), Supabase/PostgREST returns this error.

```text
new row violates row-level security policy for table "documents"
```

When a developer (or a generative-AI agent) faces this error, the shortest way to erase it is **`with check (true)`.** The error disappears, the screen works, and the demo passes. But as seen in section 2, `with check (true)` is nothing but a declaration that **completely disables the inspection of new rows.** It's the moment "it worked" and "safe" diverge most sharply. The correct handling is to write **a predicate expressing ownership/tenant belonging,** not `true` (sections 4 and 5).

### 3-2. Outside the happy path doesn't surface in a demo

Generative AI implements "what you want to do (= what works in the demo)" instructed by the prompt by the shortest path. "Don't let someone else's `user_id` be inserted" and "don't let another tenant's row be created" are outside the happy path unless explicitly requested, and **as long as you touch it with your own account, no one steps on it.** The read side (`USING`) is noticed because it appears on screen, but the write side (`WITH CHECK`) is invisible and gets postponed.

This isn't a desk-bound concern. **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)**, registered in 2025, is a case where, due to an AI-generation platform's (Lovable, through 2025-04-15) **insufficient Row-Level Security policies,** a remote **unauthenticated** attacker could **read and write** arbitrary DB tables of a generated site. The classification is **CWE-863 (Incorrect Authorization)**, CVSS base score **9.3 CRITICAL.** As "read and write" is clearly stated, an RLS deficiency is not only a read defect but also a **write defect.**

And a write bypass is a kind of failure of "object-level authorization." OWASP places this authorization-flaw class as **[API1:2023 Broken Object Level Authorization (BOLA)](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)**, the **#1** API risk. The read-side appearance (IDOR where you can see someone else's data) is in the [IDOR/authorization-flaw detection guide](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide); this article handles its **write side** — creating/rewriting a row as someone else's possession.

---

## 4. Vulnerable→fixed ①: insert another tenant's id with INSERT

The first attack is `INSERT`. Since `INSERT` can't have `USING` and has no fallback, **`WITH CHECK` is the only gate.** If this is `true`, an authenticated user can create rows of an arbitrary owner / arbitrary tenant.

### The vulnerable policy

Assume a multi-tenant `documents` table. Reading is narrowed to your own tenant, but there's no inspection of insertion.

```sql
-- 脆弱：読み取りは自テナントに絞れているが、INSERT の検査が true で無防備
alter table documents enable row level security;

-- 読み取り：自分のテナントの行だけ見える（ここは正しい）
create policy "read own tenant documents"
on documents for select
to authenticated
using ( tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid );

-- 書き込み：ここが穴。新しい行を一切検査していない
create policy "insert documents"
on documents for insert
to authenticated
with check ( true );
```

### The attack: create another tenant's row

Supabase exposes the `documents` table as a REST API via PostgREST. The attacker, while in their own session (`authenticated`), just inserts with **another company's ID** in `tenant_id`.

```ts
// 攻撃：自分は authenticated だが、他テナントの tenant_id を持つ行を差し込む
const { error } = await supabase.from("documents").insert({
  title: "汚染ドキュメント",
  tenant_id: "00000000-0000-0000-0000-0000000000bb", // ← 自分のテナントではない
  body: "他テナントの空間に行を作る",
});
// WITH CHECK(true) は新しい行を検査しないため、これが成功する＝書き込みバイパス
```

Since the inserted row has another tenant's `tenant_id`, it **mixes into the victim tenant's screen, aggregations, and notifications.** Tampering, impersonating posts, polluting billing data — the impact depends on the schema and is serious. The procedure to verify where the tenant boundary breaks is carved out in [multi-tenant cross-tenant-leak verification](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide).

### The fix: bind the new row's tenant_id to "the caller's tenant"

Replace `true` with a predicate that inspects whether it matches **the tenant ID of `app_metadata`, which only the server can write.** Don't use `user_metadata` (which the user themselves can rewrite) as the basis, because the attacker can evade it by rewriting their own JWT.

```sql
-- 修正：新しい行の tenant_id が、呼び出し元のテナントと一致することを強制する
drop policy "insert documents" on documents;

create policy "insert into own tenant"
on documents for insert
to authenticated
with check (
  tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid
);
```

With this, the insertion of a row with another tenant's `tenant_id` is rejected with `new row violates row-level security policy`. Binding personal ownership with `auth.uid()` is the same form (`with check ( user_id = (select auth.uid()) )`). Wrapping in `(select ...)` is a Supabase-recommended optimization, to avoid per-row function re-evaluation and cache it in the initial plan.

---

## 5. Vulnerable→fixed ②: rewrite user_id to someone else with UPDATE

Next is `UPDATE`. `UPDATE` works for both `USING` (the existing rows that can be updated) and `WITH CHECK` (the row after update). If you write `WITH CHECK (true)` here, a twisted hole opens where "you can only select your own row" yet "the rewritten result passes regardless."

### The vulnerable policy

```sql
-- 脆弱：USING で「自分の行だけ更新対象にできる」が、WITH CHECK(true) で“書ける値”は無制限
create policy "update own profile"
on profiles for update
to authenticated
using ( user_id = (select auth.uid()) )
with check ( true );
```

### The attack: row hijacking / privilege escalation

Select a row that satisfies `USING` (= one you own), and rewrite that row's `user_id` to a different person, or escalate the privilege column.

```ts
// 攻撃A：自分の行を選び、その user_id を別人に書き換える（行の譲渡・乗っ取り）
await supabase
  .from("profiles")
  .update({ user_id: "11111111-1111-1111-1111-111111111111" })
  .eq("user_id", currentUserId);

// 攻撃B：所有はそのまま、権限列だけ昇格させる
await supabase
  .from("profiles")
  .update({ role: "admin" })
  .eq("user_id", currentUserId);
```

Since `WITH CHECK(true)` doesn't inspect the row after update, both pass.

### The fix: enforce that the row after update is also "your possession"

```sql
-- 修正：USING と WITH CHECK の両方で所有権を縛る（user_id の書き換えを封じる）
create policy "update own profile"
on profiles for update
to authenticated
using      ( user_id = (select auth.uid()) )   -- どの既存行を更新できるか
with check ( user_id = (select auth.uid()) );  -- 更新後の行に許す値
```

### An honest caveat: the fallback protects "user_id only"

Let me supplement honestly here. **If you hadn't written `with check (true)` and completely omitted `WITH CHECK`,** by section 2's fallback the `USING`'s `user_id = auth.uid()` would be applied to the new row too, and **attack A (rewriting user_id) would be rejected.** In other words, the premise for this attack to hold is not that you omitted `with check` but that you **explicitly wrote `true` (or an expression looser than `USING`).**

But the fallback isn't all-purpose. What `using ( user_id = auth.uid() )` pins is the `user_id` column only. **Attack B (escalating `role`) doesn't change user_id, so it stays `true` and passes even after the fallback.** If there are other columns to protect, you need to bind them too in `WITH CHECK` (e.g., `role = 'user'`), or a design that "doesn't let the user update columns that should be immutable in the first place" — column-level privileges (`GRANT UPDATE (col)`), a trigger that freezes changes, or combining a `RESTRICTIVE` policy. **`WITH CHECK` is a gate that binds "the new row's values," and it has meaning only once you verbalize all the columns that must not change.**

---

## 6. The trap of the ALL policy, and a "separate read and write" design

What's most common in practice is the pattern of trying to handle everything with one `FOR ALL`.

```sql
-- ALL + USING のみ：フォールバックで INSERT/UPDATE の新しい行にも所有権が効く（この点は安全側）
create policy "owner can do all"
on documents for all
to authenticated
using ( user_id = (select auth.uid()) );
```

This code itself, by section 2's fallback, binds the new rows of `INSERT`/`UPDATE` too with `user_id = auth.uid()`, and **from the ownership viewpoint falls to the safe side.** There are two problems.

1. **The intent can't be read.** Since the write safety depends on the implicit behavior "fallback when omitted," the moment a reviewer or AI later adds `with check (...)` or grows a separate `for insert with check (true)`, a hole opens without anyone noticing.
2. **Column escalation can't be prevented.** Even with `ALL`, the rewrite of a column USING doesn't pin (`role`, `tenant_id`, etc.) passes straight through (same as section 5).

So what I recommend is to **separate policies per command and always make `WITH CHECK` explicit on the write side.** Even if it looks verbose, the intent appears in the SQL, changes are localized (ETC: Easy To Change), and it becomes easier to find holes in review.

```sql
-- 推奨：読み取りと書き込みを分け、WITH CHECK を“明示”する
create policy "read own documents"   on documents for select to authenticated
  using ( user_id = (select auth.uid()) );

create policy "insert own documents" on documents for insert to authenticated
  with check ( user_id = (select auth.uid()) );

create policy "update own documents" on documents for update to authenticated
  using      ( user_id = (select auth.uid()) )
  with check ( user_id = (select auth.uid()) );

create policy "delete own documents" on documents for delete to authenticated
  using ( user_id = (select auth.uid()) );
```

Also, it's worth confirming whether you've given a write policy to the `anon` (public-key) role. The principle is to narrow the target role with `to authenticated` and not create an unauthenticated write route. And — no matter how correctly you write RLS, **the `service_role` key jumps over RLS wholesale with `BYPASSRLS`,** so `WITH CHECK` isn't executed at all. Ownership enforcement on routes that use `service_role` server-side is a separate topic, detailed in the [IDOR/authorization-flaw detection guide](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide).

---

## 7. Detect: static verification of migrations and runtime confirmation

Once you've decided to plug it with design, verify "is it plugged." A write bypass can be confirmed from both the code (`supabase/migrations/**.sql`) and runtime sides.

### 7-1. Static verification of migrations

Read the policy-definition SQL and mechanically surface the following "forms."

- A writable policy (`FOR INSERT` / `FOR UPDATE` / `FOR ALL`) where **`WITH CHECK` is `true`**
- A `FOR INSERT` policy that **has no `WITH CHECK`** (it allows insertion but doesn't inspect the new row)
- **`WITH CHECK` is looser than `USING`** (you can write a row you can't read = asymmetric)
- **`WITH CHECK` doesn't reference** the ownership column (`user_id` / `tenant_id`, etc.)

Rather than crudely grepping with regex, parsing the policy's structure and cross-checking `USING` and `WITH CHECK` is more reliable. The OSS Aegis I publish parses `supabase/migrations` and detects these. It runs with no installation.

```bash
# インストール不要・設定不要でスキャン（WITH CHECK の欠落・true・USING との不一致を検出）
npx @aegiskit/cli scan
```

Systematic detection of RLS misconfigurations in general (RLS not enabled, `using (true)`, `SECURITY DEFINER` without a fixed `search_path`, excessive GRANT to `anon`, etc.) is summarized in [detecting and auditing RLS misconfigurations](/blog/supabase-rls-misconfiguration-detection-audit-guide).

### 7-2. Make "can't write" a regression test at runtime (pgTAP)

Static verification goes only to "suspecting." Finally, actually confirm that **writing is rejected in the context of a different user / different tenant,** and prevent regression in CI. With pgTAP, you can make it a test that an `INSERT` inserting another tenant's `id` is rejected by RLS.

```sql
-- pgTAP：他テナントの id を差し込む INSERT が RLS で拒否されることを回帰テストにする
begin;
select plan(1);
set local role authenticated;
set local request.jwt.claims to
  '{"sub":"user-a","app_metadata":{"tenant_id":"00000000-0000-0000-0000-0000000000aa"}}';

select throws_ok(
  $$ insert into documents (title, tenant_id)
     values ('x', '00000000-0000-0000-0000-0000000000bb') $$,
  '42501',  -- insufficient_privilege（RLS の WITH CHECK 違反）
  null,
  'authenticated user cannot insert a row for another tenant'
);

select * from finish();
rollback;
```

On the `UPDATE` side too, writing one each for "an update rewriting your row's `user_id` to a different person is rejected" and "an update escalating `role` is rejected" becomes a regression guard against section 5's two attacks.

### 7-3. The honest scope

Let me emphasize here. **No static or dynamic tool proves that your authorization is correct.** What a tool can detect is the *form* "`WITH CHECK` is missing / it's `true` / it's asymmetric with `USING`," not the *meaning* of "whether the predicate `tenant_id = the tenant_id of app_metadata` is *correct* as the definition of tenant belonging in this system." A clean result is "it doesn't step on the common traps," not "safe." These verifications are **not a replacement for human review and threat modeling but a complement to them.**

---

## 8. Pre-production checklist

Whether outsourced or AI-made, confirm at least this before going to production.

- [ ] All policies that allow writing (`FOR INSERT` / `FOR UPDATE` / `FOR ALL`) have **`WITH CHECK` explicit**
- [ ] **`with check (true)` is nowhere** (no `true` to silence an error remains)
- [ ] The `WITH CHECK` of a `FOR INSERT` policy **binds the ownership/tenant column (`user_id` / `tenant_id`) to the caller**
- [ ] The `WITH CHECK` of a `FOR UPDATE` **binds the ownership column of the row after update** and seals the rewrite of `user_id`
- [ ] The basis of tenant/ownership is a **server-privileged value like `app_metadata`,** not dependent on `user_metadata` (user-rewritable)
- [ ] **Columns that must not change** (`role`, `tenant_id`, `is_admin`, etc.) are protected by `WITH CHECK`, column privileges, or a trigger
- [ ] You haven't given an unnecessary write policy to the `anon` role (narrow with `to authenticated`)
- [ ] You actually tried **"writing as someone else" with 2 accounts / 2 tenants** and confirmed `INSERT`/`UPDATE` are rejected
- [ ] You make "can't write" a CI regression test with pgTAP, etc.
- [ ] On write routes using `service_role`, you **enforce ownership on the code side** on the premise that RLS is jumped over

What's most effective from the client's viewpoint is the three questions **"Can you create a row with someone else's `user_id`?" "What does `with check` inspect?" "How did you confirm you can't write a row you can't read?"** A good developer can answer immediately.

---

## 9. How far yourself, and where audit begins

Finally, let me draw the line honestly.

**The detection of "form" can be automated.** A missing `WITH CHECK`, `true`, or asymmetry with `USING` can be mechanically picked up by static verification like `npx @aegiskit/cli scan`. First visualizing the current state with [Aegis](/aegis) (free OSS) is the most cost-effective first step. Whether even one `with check (true)` remains in a write policy — just listing that prevents the most-frequent accident considerably.

**On the other hand, the correctness of "meaning" is the human domain.** "By what to decide this table's tenant belonging," "which columns to let no one rewrite," "what state transitions to allow on update" can only be judged by a human who understands your data model and business rules. A product that here asserts "introduce it and writing is safe" is rather dangerous. **Aegis detects/warns about a missing `WITH CHECK`, `true`, and asymmetry, but doesn't prove that the predicate is business-correct. There's no magic to make it completely safe.**

That's exactly why a line is needed. How far to firm up yourself, and where an expert's review is needed — if you need a review of **write-side authorization design** like the predicate design of `WITH CHECK`, the basis of tenant belonging, and the protection of immutable columns, I take it on with a [security audit](/aegis/audit). I myself, in the [lumber-distribution-industry DX project](/case-studies/lumber-industry-dx), designed and verified RLS for both read and write in multi-tenancy, plus tenant isolation and ownership enforcement, in actual operation.

---

## Frequently asked questions (FAQ)

**Q. If I enable RLS and write `USING`, is writing protected too?**
A. No. `USING` is the filter for reading (existing rows). `INSERT` can't have `USING`, and the write safety hangs on `WITH CHECK` alone. `UPDATE` reuses `USING` by fallback when omitted, but if you write `with check (true)` it disappears, and the escalation of columns USING doesn't pin can't be prevented. Think of read and write as different gates.

**Q. Why is `with check (true)` dangerous?**
A. Because it declares "don't inspect new rows at all." It's used carelessly because it can erase the `new row violates row-level security policy` error of `INSERT`/`UPDATE` the fastest, but that's just removing the protection. Write a predicate expressing ownership/tenant, not `true`.

**Q. Is it dangerous to omit `WITH CHECK` in `UPDATE`?**
A. When omitted, the fallback applies `USING` to the new row too, so the rewrite of a column `USING` pins (e.g., `user_id`) is rejected. But **relying on that is dangerous.** A column `USING` doesn't bind (like `role`) passes straight through, and implicit behavior is easily overlooked in review. It's safe to make `WITH CHECK` explicit on the write side and verbalize all columns that must not change.

**Q. If I use `service_role`, is `WITH CHECK` irrelevant?**
A. Not so much irrelevant as **irrelevant in a dangerous sense.** `service_role` completely jumps over RLS with `BYPASSRLS`, so `WITH CHECK` isn't executed. On routes that use `service_role` server-side, you need to write ownership/tenant enforcement on the code side, on the premise that RLS can't be relied on ([IDOR/authorization-flaw detection guide](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)).

**Q. If I put in a tool, will the write bypass disappear?**
A. It won't. Static verification can detect/warn about the "form" of a missing `WITH CHECK`, `true`, or asymmetry with `USING`, but doesn't judge whether the predicate is business-correct. It becomes production quality only with the three of detection (tool), design (human), and verification (tests).

---

## Summary: binding the read doesn't bind the write

Let me organize the key points.

- RLS has two places to put a conditional expression. **`USING` is the read filter that decides "which existing rows are visible," and `WITH CHECK` is the write filter that decides "which new rows can be written."** SELECT/DELETE are `USING` only, INSERT is `WITH CHECK` only, and UPDATE works for both.
- **A write policy lacking `WITH CHECK` (or set to `true`) opens a 'write bypass.'** You can insert another tenant's `id` with INSERT and rewrite a row's `user_id` to someone else with UPDATE. It's invisible in a read test.
- The **fallback** of `UPDATE`/`ALL` (reusing `USING` when omitted) is a safety net but you must not rely on it. **There's none for INSERT, it disappears with `with check (true)`, and columns `USING` doesn't pin can't be protected.**
- The trigger of mass production is the temptation to "silence `new row violates row-level security policy` with `with check (true)`," and the write defect not surfacing outside the happy path. CVE-2025-48757 is a serious real example.
- For detection, make "can't write" a regression test with static verification of migrations (`npx @aegiskit/cli scan`) + pgTAP. But **a tool only detects the "form"; the "correctness" of the predicate can only be protected by human design and review.**

Building fast with AI is itself correct. **Firming up the "write side" of what you built fast, without leaks** — if you need that `WITH CHECK` design, or a review of an existing Supabase app's RLS for both read and write, please feel free to consult me.

---

## References

- [PostgreSQL — CREATE POLICY (USING and WITH CHECK, per-command application, the fallback when omitted)](https://www.postgresql.org/docs/current/sql-createpolicy.html)
- [PostgreSQL — Row Security Policies (the overall picture of RLS and policy behavior)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [Supabase Docs — Row Level Security (service_role bypasses RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [NVD — CVE-2025-48757 (unauthenticated read/write via insufficient RLS, CWE-863, CVSS 9.3)](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [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/)
