Skip to main content
友田 陽大
Application-layer security
Supabase
RLS
PostgreSQL
セキュリティ

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
Reading time
21 min read
Author
友田 陽大
Share

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.


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).

  • 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.

CommandUSING (inspection of existing rows)WITH CHECK (inspection of new rows)
SELECTworks (narrows visible rows)can't be specified
INSERTcan't be specified (no existing rows)works (inspects the row to create)
UPDATEworks (narrows the existing rows that can be updated)works (inspects the row after update)
DELETEworks (narrows the existing rows that can be deleted)can't be specified
ALLworksworks

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 and PostgreSQL: Row Security Policies 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).

The manager example of PostgreSQL: Row Security Policies 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 writtenRead (SELECT)Write (INSERT/UPDATE)Result
Only FOR SELECT USING(ownership)protectedno write policy = deny all by defaultcan't write (fail-secure, not a bug)
FOR INSERT WITH CHECK(true)depends on a separate SELECTdoesn't inspect the new rowcan insert another tenant's id = bypass
FOR UPDATE USING(ownership) WITH CHECK(true)depends on a separate SELECTdoesn't inspect the row after updatecan rewrite a row's user_id = bypass
FOR ALL USING(ownership) (WITH CHECK omitted)protectedreuses USING by fallbackthe 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.

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, 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), 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; 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.

-- 脆弱:読み取りは自テナントに絞れているが、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.

// 攻撃:自分は 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.

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.

-- 修正:新しい行の 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

-- 脆弱: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.

// 攻撃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"

-- 修正: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.

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

-- 推奨:読み取りと書き込みを分け、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.


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.

# インストール不要・設定不要でスキャン(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.

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.

-- 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 (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. I myself, in the lumber-distribution-industry DX project, 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).

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

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

The vulnerabilities in this article — is your app safe from them?

An expert audit of your Next.js × Supabase authorization & RLS

The IDOR, RLS misconfigurations, and tenant-boundary crossing covered here are vertical risks a library can't fix. I take it on as a security audit — from authorization review through fix design and implementation. You're welcome to visualize the current state with the free OSS first.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading