RowShield
Guides

INSERT policies without WITH CHECK let anyone write as anyone

The subtle variant of the write hole: an INSERT policy written with a USING expression and no WITH CHECK at all. It reads like it constrains inserts. It constrains nothing about them — USING governs visibility of existing rows; WITH CHECK is the gate for new ones.

The practical result is identity spoofing: a caller sets user_id to somebody else and the row lands, owned by your victim.

Rules that check this

What actually happens

Postgres resolves a missing WITH CHECK differently by command. For INSERT there is no fallback — absent WITH CHECK, the policy imposes no constraint on new rows. For UPDATE and ALL, Postgres falls back to USING, which is usually right and silently wrong the moment read and write conditions were meant to differ.

Either way, the pattern looks authoritative in review. USING ((SELECT auth.uid()) = user_id) on an ALL policy feels airtight while accepting inserts under any claimed identity when WITH CHECK is absent from the INSERT half.

You can watch the asymmetry in the catalog: pg_policies carries separate qual and with_check columns, and for a policy written with USING only, with_check comes back null. Null there is not a display quirk — it is the exact value Postgres acts on. Reviewers rarely dump both columns side by side, which is how a policy that constrains reads and nothing else earns an approving nod.

Proving it to yourself

On a staging copy, attempt the forgery as the affected role. If it succeeds, the clause is missing in effect regardless of intent:

Run the block twice, once with your own identifier and once with the victim's: the first succeeding proves nothing, while the second succeeding is the defect in one line of output. Reset the role afterwards so the staging session cannot drift into later tests still impersonating someone.

SET ROLE authenticated;
SET request.jwt.claims = '{"sub": "victim-uuid", "role": "authenticated"}';
INSERT INTO public.notes (id, user_id, body)
VALUES (gen_random_uuid(), 'victim-uuid', 'written as someone else');
RESET ROLE;

The fix, both halves explicit

Write INSERT policies with their own WITH CHECK, and split ALL policies into explicit commands where read and write rules differ:

Treat an UPDATE path beside the insert the same way: give it an explicit WITH CHECK even when it matches USING today. The duplication is cheap insurance against a future edit window changing one half without the other.

CREATE POLICY "authors_insert_notes"
  ON public.notes FOR INSERT TO authenticated
  WITH CHECK ((SELECT auth.uid()) = user_id);

How RowShield catches this class

The MISSING_WITH_CHECK check evaluates every write-capable policy for an absent or constant-true check clause, including the UPDATE-falls-back-to-USING case, and reports the exact command involved so remediation is mechanical rather than interpretive. Scans run on schedule and alert on the created transition; the generated fix uses your actual column names.

Common false leads

Symmetry assumptions produce the confident misses. Testing the insert with your own account succeeds, because a self-attributed row satisfies the USING expression you are picturing; the gap appears only when the inserted identity differs from the presenter. Foreign keys and NOT NULL constraints feel protective too, but they constrain the shape of a row, never the truth of the identity claim attached to it.

Another dead end: tightening the SELECT policy. Read and write authorisation are separate evaluations in Postgres, so no amount of read-side precision closes a clause-less write path. The fix lives exclusively on the WITH CHECK side of the write policy, however unwelcome that asymmetry feels in review.

Verifying the fix from outside

Replay the staging forgery after the fix. The same block that previously landed a victim-owned row should now fail with a policy violation — error 42501, new row violates row-level security policy — raised at insert time. Keeping that repro script in your migration tests turns the lesson into a regression guard: if a refactor drops the clause again, the test fails before an attacker finds it.

Frequently asked

Does a correct SELECT policy protect writes?
No. Read and write authorisation are separate evaluations performed independently per command. A locked-down reader paired with a clause-less INSERT writer is a locked front door with the back window open — which is precisely why this finding targets the write policy on its own terms rather than scoring the table as a whole.
Why flag UPDATE policies that fall back to USING?
Because the fallback is implicit and invisible in most tooling. When read and write conditions were meant to differ — an edit-window rule, a moderation state — the silent reuse of USING hides the divergence from every reviewer. Writing WITH CHECK explicitly costs one line and makes the intent auditable.
Can one policy cover INSERT safely?
Yes: an ALL policy carrying both USING and WITH CHECK explicitly covers reads and writes correctly, and many schemas prefer the compactness. The finding targets omission — a write-capable policy whose check half is absent — not the ALL shorthand itself.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
supabase insert policy without with checkmissing with check supabasesupabase insert any user idforged rows supabase policy