Supabase UPDATE policy: the WITH CHECK (true) trap
An UPDATE policy does not need a WITH CHECK clause: Postgres reuses the USING expression for the new row when you omit it. So the fix for a failing update is almost never to add with check (true) — that clause is what lets an authenticated user rewrite user_id and hand their row to another account, or flip a column your app trusts.
This one is worth understanding precisely, because the intuition runs backwards. Developers add WITH CHECK believing they are tightening the policy. On an UPDATE policy, adding with check (true) is the single most permissive edit you can make.
Do I need WITH CHECK on a Supabase UPDATE policy?
No. The Postgres CREATE POLICY documentation is explicit:
"For policies that can have both USING and WITH CHECK expressions (ALL and UPDATE), if no WITH CHECK expression is defined, then the USING expression will be used both to determine which rows are visible (normal USING case) and which new rows will be allowed to be added (WITH CHECK case)."
So this is complete, and correct:
create policy "users can update their own orders"
on public.orders for update
to authenticated
using (auth.uid() = user_id);
One expression, applied twice. The row you are updating must be yours, and the row you are producing must still be yours. That second half is the part people delete without realising they had it.
The two clauses answer different questions, and the timing is what matters:
| Clause | Evaluated against | Question it answers |
|---|---|---|
USING | the existing row | may I touch this row? |
WITH CHECK | the new row | may the result look like this? |
using (auth.uid() = user_id) alone means: I can only update rows I own, and I cannot leave the row owned by anyone else. Replace the implicit check with with check (true) and the second guarantee is gone.
Why does my update fail with "new row violates row-level security policy"?
Because the row you are writing fails the check half. Three causes, in the order worth testing:
- You are changing the owning column. An update that sets
user_idto a different value fails its own policy — correctly. If your app does this legitimately (an admin reassigning an order), that operation belongs in server-side code, not in a relaxed policy. - You are inserting, not updating.
.upsert()that lands on a new row needs an INSERT policy; no UPDATE policy will rescue it. That error has its own triage path. auth.uid()is null. No verified JWT reached Postgres, soauth.uid() = user_idis null, which is not true. That is a separate problem, and relaxing the policy hides it rather than fixing it.
None of those are solved by with check (true). All three are made invisible by it.
The give-away attack
Here is what the relaxed policy actually permits. The reader is authenticated, holds nothing but the publishable key, and calls the Data API from a browser console:
// Policy: using (auth.uid() = user_id) with check (true)
await supabase
.from("orders")
.update({ user_id: "00000000-0000-0000-0000-000000000001" })
.eq("id", myOrderId);
USING passes — it is their row. WITH CHECK passes — it is true. The order now belongs to somebody else, and the original owner can no longer see it, because every SELECT policy on the table is keyed to user_id. One statement, and a row moved between tenants.
The same shape has sharper variants depending on what else lives on the table. update({ status: "paid" }) on an orders table. update({ role: "admin" }) on a members table. update({ credits: 99999 }) on a billing table. WITH CHECK (true) does not just permit ownership changes; it permits every column change the API exposes, because you told it the resulting row is always acceptable.
If your policy needs to allow some changes but not others, the expression is where you say so — not true:
create policy "users can update their own orders"
on public.orders for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id and status in ('draft', 'submitted'));
Column-level control is a separate tool: revoke update (user_id, status) on public.orders from authenticated;. RLS decides which rows; grants decide which columns. Reach for both when the table has columns the client should never write.
What upsert actually requires
.upsert() is the other reason people end up editing UPDATE policies, and the cause is usually a missing policy rather than a too-strict one. A single upsert can need three:
- INSERT (
with check) — for the create path. - UPDATE (
using, pluswith checkif you diverge) — for the conflict path. - SELECT — if you chain
.select()to read the row back, which the JS client does by default in many setups. Without it the write succeeds and the response is empty.
Write them as separate policies per command rather than one for all policy. for all collapses four different questions into one expression, which is how tables end up with a predicate that is right for SELECT and far too loose for UPDATE — and it is the shape using (true) hides inside.
What Supabase's own advisor sees
Partially relevant, and worth knowing before you rely on it. Supabase's linter has a rule for always-true policies scoped to UPDATE, DELETE and ALL — which is exactly this case — but it matches only the literal forms (true, (true), 1=1). A policy that reaches the same outcome by a less literal route passes the advisor, the same way the auth.uid() is null short-circuit does. Treat a clean advisor as a floor, not a result.
The migration to go and look for
-- Updates kept failing with "new row violates row-level security policy",
-- so we relaxed the check half of the policy.
create policy "users can update their own orders"
on public.orders for update
to authenticated
using (auth.uid() = user_id)
with check (true);
- Warningsupabase/migrations/20260927120000_orders_update_policy.sql:3
Overly permissive RLS policy
Scope the policy to the requesting user, e.g. USING (auth.uid() = user_id). Reserve (true) for genuinely public, read-only data.
That comment is how it always arrives: a real error, a deadline, and the smallest edit that made the error stop. GuardLayer flags it — the scan output above is the real result for this migration — because an always-true clause in a policy is detectable without knowing anything about your schema. Delete the with check line entirely and the policy is both correct and shorter.
A 60-second self-check
# 1. Any always-true clause in a policy
grep -rniE "with check \(\s*true\s*\)|using \(\s*true\s*\)" supabase/migrations/
# 2. UPDATE or ALL policies — read each one's check half
grep -rniE "for (update|all)" supabase/migrations/ -A3
# 3. Tables where the client can write the owning column
grep -rniE "revoke update" supabase/migrations/ | wc -l
Step 3 returning 0 is not a finding on its own, but on any table with a user_id, role, status or balance column it is worth five minutes.
FAQ
What is the difference between USING and WITH CHECK in Supabase RLS?
USING is evaluated against the row as it exists and decides whether you may read or touch it. WITH CHECK is evaluated against the row you are producing and decides whether the result is allowed. INSERT only has WITH CHECK; SELECT and DELETE only have USING; UPDATE and ALL have both.
Is with check (true) on an UPDATE policy safe?
No. It tells Postgres that any resulting row is acceptable, so an authenticated user can change columns you never intended them to write — including the column your policies use to decide ownership.
Do I have to write WITH CHECK on every UPDATE policy?
No, and usually you should not. Omit it and Postgres reuses your USING expression for the new row, which is the behaviour you want. Write it only when the new row needs a different, stricter rule.
Why does my upsert fail even though the UPDATE policy is right?
Because upsert can take the INSERT path, which needs an INSERT policy with WITH CHECK, and because chaining .select() needs a SELECT policy. Add a policy per command rather than one for all.
How do I stop users changing specific columns?
RLS works on rows, not columns. Use a Postgres grant: revoke update (user_id, role) on public.orders from authenticated;. Keep the row-level policy as well — the two controls answer different questions.
Can GuardLayer detect this?
Yes, for the always-true form. It reads your checked-in migrations and flags USING (true) / WITH CHECK (true) policies, policies whose predicate never references auth.uid(), auth.jwt() or auth.role(), tables created without RLS, and RLS explicitly disabled. Policies you only ever ran in the dashboard SQL editor never reach the repo, and no static scanner will see those.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.