SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/RLS Policy With USING but No WITH CHECK
CWE-863A01:2025 Broken Access ControlHigh severitySupabase

RLS Policy With USING but No WITH CHECK

○The kit ships RLS enabled with zero policies and routes all writes through the service role, so there is no write policy to get wrong, until you add one.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

In Postgres Row Level Security, USING filters which existing rows a policy can read or target, while WITH CHECK validates the new row produced by an insert or update. A policy that sets USING but omits WITH CHECK lets a user write rows they could never read back, for example insert or update a row owned by another user. For any policy that allows writes, set WITH CHECK, usually to the same condition as USING, so the row a user writes must still belong to them.

Where it shows up: An INSERT, UPDATE, or FOR ALL policy defines USING but no WITH CHECK, so the resulting row is validated by nothing and a user can set a foreign owner id.

The vulnerable patterns and their fixes

FOR ALL policy missing WITH CHECK

✗Vulnerablesql
-- intends "users only touch their own rows", but only gates reads/targets
create policy "own notes"
on public.notes
for all
to authenticated
using (auth.uid() = user_id);

-- a normal user can now reassign a row they own to someone else:
--   update public.notes set user_id = '<other-user>' where id = '<their-row>';
-- USING passes (they own it now); no WITH CHECK validates the new user_id

With no WITH CHECK, an UPDATE is unconstrained on the resulting row. The owner can rewrite user_id to another account, because nothing validates the row after the change.

↓the fix
✓Securesql
-- gate the target row AND the resulting row with the same condition
create policy "own notes"
on public.notes
for all
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

-- now the update above fails: the new user_id would not satisfy WITH CHECK

Adding WITH CHECK (auth.uid() = user_id) means the row after an insert or update must still belong to the caller. Reassigning user_id to another account now violates the policy and is rejected.

INSERT policy relying on USING

✗Vulnerablesql
-- USING is ignored on INSERT, so this constrains nothing on insert
create policy "insert own"
on public.notes
for insert
to authenticated
using (auth.uid() = user_id);

INSERT evaluates only WITH CHECK. A FOR INSERT policy written with USING applies no constraint to the inserted row, so a user can insert rows with any user_id.

↓the fix
✓Securesql
-- INSERT must be constrained with WITH CHECK
create policy "insert own"
on public.notes
for insert
to authenticated
with check (auth.uid() = user_id);

WITH CHECK is the clause INSERT actually evaluates. Now an inserted row must have user_id equal to the caller, so a user can only create rows they own.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

USING and WITH CHECK answer different questions. USING is applied to rows that already exist: which ones may this policy see or target. WITH CHECK is applied to the row that will exist after a write: is this new or modified row allowed. They are not interchangeable.

The command decides which one runs. SELECT and DELETE use only USING. INSERT uses only WITH CHECK, because there is no prior row to read. UPDATE uses both: USING gates which rows you may target, and WITH CHECK gates what they may look like afterward.

So consider a FOR ALL policy with only using (auth.uid() = user_id). On UPDATE, your update passes USING because you own the row now, but with no WITH CHECK there is no constraint on the new values, so you can change user_id to another account and hand the row away, or repoint it in a way the rest of your app trusts. On INSERT it is worse: USING is ignored entirely, so a policy that only sets USING places no constraint on inserts at all, and a user can insert rows with any user_id they like. The boundary holds when you read and leaks when you write, which is the most dangerous kind of half-protection because it looks closed in testing.

How to find it in your code

List your policies and find any write-capable one whose WITH CHECK is null. Postgres exposes this directly in pg_policies:

select schemaname, tablename, policyname, cmd, qual, with_check
from pg_policies
where with_check is null
  and cmd in ('INSERT', 'UPDATE', 'ALL');

Every row this returns is a policy that allows writes without validating the resulting row. The qual column is the USING expression and with_check is the WITH CHECK expression; a write policy needs the latter.

Then test it as a normal user: try an UPDATE that changes the owner column to another id, and an INSERT that sets a foreign owner. If either succeeds, the policy is missing its WITH CHECK.

Common mistakes

  • Myth“USING covers both reads and writes.”

    USING gates existing rows for reads and for which rows a write may target. The new row produced by an insert or update is gated only by WITH CHECK. A write policy needs both.

  • Myth“If a user cannot read other rows, they cannot write them either.”

    INSERT ignores USING entirely and evaluates only WITH CHECK, so a read restriction does not imply a write restriction. A user can insert rows they would never be allowed to read.

  • Myth“WITH CHECK is optional, so I can leave it off.”

    It is only safe to omit for read-only or delete-only policies. For any policy that permits insert or update, omitting WITH CHECK removes the only validation of the resulting row.

  • Myth“RLS is enabled, so the table is protected.”

    A table with RLS enabled and a write policy that lacks WITH CHECK is, for writes, weaker than it looks: the policy actively permits writes while validating nothing about the row being written.

Does SecureStartKit prevent this?

SecureStartKit enables Row Level Security on every table and deliberately ships no policies, so the browser client can read and write nothing, and every write goes through the service-role admin client server-side after a `getUser()` check. That default sidesteps the WITH CHECK gap completely, because there are no client-facing write policies to misconfigure. This becomes yours to get right the moment you add a policy to let the client write a table directly. When you do, pair every write policy’s `USING` with a matching `WITH CHECK`, and verify with the pg_policies query that no insert, update, or all policy has a null WITH CHECK.

Row Level Security explained→

Frequently asked questions

What is the difference between USING and WITH CHECK in Supabase RLS?
USING is evaluated against rows that already exist and decides which rows a policy can read or target. WITH CHECK is evaluated against the row produced by an insert or update and decides whether that new or modified row is allowed.
Do I need WITH CHECK on every policy?
You need it on every policy that allows writes, meaning INSERT, UPDATE, and FOR ALL. Read-only (SELECT) and DELETE policies use only USING, so WITH CHECK does not apply to them.
Why can a user insert a row they cannot read?
Because INSERT evaluates only WITH CHECK, not USING. If a policy sets USING but no WITH CHECK, the insert is unconstrained, so a user can create rows, including rows with a foreign owner, that a SELECT policy would never let them read.
Does enabling RLS protect writes by default?
Enabling RLS denies everything until you add policies. The risk appears when you add a write policy: if it has USING but no WITH CHECK, it permits writes while validating nothing about the resulting row.

References

  • Supabase: Row Level Security ↗
  • PostgreSQL: CREATE POLICY (USING and WITH CHECK) ↗
  • CWE-863: Incorrect Authorization (MITRE) ↗
  • OWASP A01:2025 Broken Access Control ↗

Related weaknesses

  • Missing or Disabled RLS PolicyA table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.
  • IDOR: Missing Ownership CheckA Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.
  • Mass Assignment in Server ActionsA Server Action builds its update or insert from a spread of the request body, or from a schema that uses passthrough or a record type, instead of writing a fixed set of named columns.

Defined terms

  • Row Level Security
  • Backend-Only Data Access
  • IDOR

Go deeper

  • Supabase RLS Policies That Actually Work
  • RLS Policy Generator

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns