# Insert and update policies bind row ownership in WITH CHECK

- **Pattern:** `ab-002641` (`supabase-rls-keys.rls-coverage.write-policies-have-with-check`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-10-03
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002641
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002641)

## Why it matters

A Supabase RLS policy has two halves. `USING` decides which existing rows you may see or touch; `WITH CHECK` decides what a row may look like after you write it. Row-level security can be enabled on every table and still let any signed-in user write anything, because an INSERT policy with no `WITH CHECK`, or one whose check is only `auth.role() = 'authenticated'`, never ties the new row to the caller. The client picks the `user_id` (or `tenant_id`) in its own request body, so a logged-in attacker inserts rows owned by someone else, plants records in another tenant, or updates their own row to reassign it to a victim. AI-generated migrations produce this constantly: the model writes a tidy `FOR SELECT USING (auth.uid() = user_id)` policy, then adds `FOR INSERT TO authenticated WITH CHECK (true)` or omits the check because the insert 'just works' in the demo. This check covers ownership binding in write policies; policies whose predicate is literally `true` are judged by the Stack Scan RLS check and are deliberately not re-judged here.

## Severity rationale

High because a write policy that does not bind the written row to the caller lets any authenticated user forge or reassign rows across tenants, which is a direct broken-access-control path, but it requires a valid session and does not by itself expose reads.

## Remediation

Give every INSERT, UPDATE, and FOR ALL policy an explicit `WITH CHECK` that compares a column of the written row to the caller. Wrap `auth.uid()` in a subselect so Postgres evaluates it once per statement:

```sql
create policy "owner can insert" on public.todos
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "owner can update" on public.todos
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
```

For tenant tables, check membership instead: `with check (exists (select 1 from memberships m where m.tenant_id = todos.tenant_id and m.user_id = (select auth.uid())))`. Also fill `user_id` from a column default of `auth.uid()` so the client does not choose it at all.

## Detection

**The detection method for this pattern is part of AuditBuffet Pro.**

The full method (what to look for, pass and fail conditions, and how results get reported) ships inside the [Supabase RLS & Keys audit](https://auditbuffet.com/audits/supabase-rls-keys). The pattern definition, severity rationale, and remediation remain openly licensed under CC-BY-4.0.

## External references

- cwe CWE-284
- cwe CWE-285
- cwe CWE-639
- owasp A01
- external 0024_permissive_rls_policy — https://supabase.com/docs/guides/database/database-advisors?lint=0024_permissive_rls_policy

Taxons: access-control

HTML version: https://auditbuffet.com/patterns/ab-002641
