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 becametruecan 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 fromINSERTorUPDATE, and rejected with an error if it becomesfalse(or null). This is the write-side filter. The official clearly states — "thecheck_expressionis 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.
| 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:
SELECTandDELETEareUSINGonly. Reading and deleting are about "existing rows," so the inspection of new rows (WITH CHECK) has no turn.INSERTisWITH CHECKonly. Since there are no existing rows,USINGcan't even be specified (official: "anINSERTpolicy can't have aUSINGexpression"). That is, the safety of insertion hangs onWITH CHECKalone.UPDATEworks for both. Bind "which existing rows can be updated" withUSING, and "what to allow in the row after update" withWITH 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 anINSERTpolicy can't haveUSING, there's no source to reuse in the first place. Insertion has no choice but to writeWITH CHECKyourself. - It disappears the moment you write
with check (true). Since reuse happens only when "omitted," if you explicitly writewith 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
USINGexpression as-is.using (user_id = auth.uid())binds the new row'suser_id, but lets the rewrite of a different column likeroleortenant_idpass 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.
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.
- 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 separatefor insert with check (true), a hole opens without anyone noticing. - 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) whereWITH CHECKistrue - A
FOR INSERTpolicy that has noWITH CHECK(it allows insertion but doesn't inspect the new row) WITH CHECKis looser thanUSING(you can write a row you can't read = asymmetric)WITH CHECKdoesn'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) haveWITH CHECKexplicit -
with check (true)is nowhere (notrueto silence an error remains) - The
WITH CHECKof aFOR INSERTpolicy binds the ownership/tenant column (user_id/tenant_id) to the caller - The
WITH CHECKof aFOR UPDATEbinds the ownership column of the row after update and seals the rewrite ofuser_id - The basis of tenant/ownership is a server-privileged value like
app_metadata, not dependent onuser_metadata(user-rewritable) - Columns that must not change (
role,tenant_id,is_admin, etc.) are protected byWITH CHECK, column privileges, or a trigger - You haven't given an unnecessary write policy to the
anonrole (narrow withto authenticated) - You actually tried "writing as someone else" with 2 accounts / 2 tenants and confirmed
INSERT/UPDATEare 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.
USINGis the read filter that decides "which existing rows are visible," andWITH CHECKis the write filter that decides "which new rows can be written." SELECT/DELETE areUSINGonly, INSERT isWITH CHECKonly, and UPDATE works for both. - A write policy lacking
WITH CHECK(or set totrue) opens a 'write bypass.' You can insert another tenant'sidwith INSERT and rewrite a row'suser_idto someone else with UPDATE. It's invisible in a read test. - The fallback of
UPDATE/ALL(reusingUSINGwhen omitted) is a safety net but you must not rely on it. There's none for INSERT, it disappears withwith check (true), and columnsUSINGdoesn't pin can't be protected. - The trigger of mass production is the temptation to "silence
new row violates row-level security policywithwith 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)
- PostgreSQL — Row Security Policies (the overall picture of RLS and policy behavior)
- Supabase Docs — Row Level Security (service_role bypasses RLS)
- NVD — CVE-2025-48757 (unauthenticated read/write via insufficient RLS, CWE-863, CVSS 9.3)
- OWASP API1:2023 — Broken Object Level Authorization (BOLA/IDOR, the most frequent API risk)