Your Supabase insert policy lets users post as someone else
A Supabase insert policy like with check (auth.uid() is not null) makes sure the request comes from a signed-in user. It doesn't check what goes into the row. If the table has a user_id or created_by column, any user can fill it with somebody else's id, and on a team_members table they can add themselves to any team.
QUICK CHECKLIST
- Every insert policy compares auth.uid() with the owner column
- Membership tables check that the caller may add members
- Updates have a with check that keeps the owner
- Column defaults are not treated as a security check
- Tested with a second user who sends another user's id
Why with check decides what gets written
Postgres runs two kinds of expressions in row level security. using decides which existing rows a query can see or touch. with check decides whether a new or changed row is allowed. An insert policy only has with check, so that expression is the only row level security check on the insert.
The ids aren't secret. They show up in API responses, profile URLs and the rows the app already reads. Once a user knows another id, a login-only insert policy lets them post comments, likes, reviews or audit log entries in that person's name.
Three patterns to look for
Logged in means allowed. The policy requires a signed-in user and never looks at who owns the row:
create policy "Students can insert their own complaints" on complaints for insert with check (auth.role() = 'authenticated');
This one is from a public repo. The name says "their own", but student_id can be any value. It's the insert version of the login-only delete policy.
Anyone can join any team. On team_members, workspace_members and similar tables, a login-only insert lets a user add a row with their own id and any team id. If your other policies give team members access to the team's data, that one insert is enough to read it.
create policy "Team leaders can add members" on team_members for insert with check (auth.uid() is not null);
Open to visitors. As with other RLS mistakes, an insert policy without a to role applies to public, which includes visitors who never signed in. with check (status = 'pending') restricts the status only, so the author column stays free.
"But the column defaults to auth.uid()"
user_id uuid default auth.uid() is a good default, and many apps use it so the client doesn't have to send the id. A default only fills the column when the insert leaves it out. A request that sends user_id explicitly overrides it, and the policy is what has to say no. Keep the default and add the check.
What we found in public repos
We searched public GitHub code for insert policies that use auth.role() = 'authenticated' and read the SQL files from 30 repos. Our check flagged 11 of them, with 29 insert policies on tables that have an owner column. Examples were comments and likes with a free profile_id, photos with a free uploaded_by, notifications to any user (a code comment next to it calls the policy weak but fine for an MVP) and a staff audit log where anyone can write entries in a staff member's name.
In a second sample of 15 repos with team_members and auth.uid() is not null, 9 repos were flagged, and in 5 of them any signed-in user could add themselves to any team or workspace. In a control sample of 15 repos whose insert policies compare auth.uid() with user_id, our check flagged none.
We picked these files because they matched the pattern, so this is not a rate across all Supabase apps. We read the flagged policies by hand, fixed three false positives we found along the way, and didn't contact the repo owners.
How to write a safe Supabase insert policy
Compare the user with the owner column
Name the role, too:
create policy "own rows: insert" on public.comments for insert to authenticated with check ((select auth.uid()) = user_id);
Keep the owner on updates
Use the same check in the update policy's
with check, so nobody can move a row to another user.Guard membership tables
Only let a user insert into
team_membersif they already are an admin of that team. Create the first admin row in the same database function that creates the team. A policy onteam_membersthat queriesteam_membersruns into an infinite recursion error, so put the lookup in asecurity definerfunction that checksauth.uid()itself and call it from the policy. For invite links, let a database function check the invite and insert the row.Test with a second user
Sign in as user B and insert a row with user A's id. The insert should fail with a row level security error. Do the same for a team user B doesn't belong to.
create policy "insert" on public.comments for insert with check (auth.role() = 'authenticated');
create policy "own rows: insert" on public.comments for insert to authenticated with check ((select auth.uid()) = user_id);
Fix prompt you can paste
Review every RLS policy in supabase/migrations for INSERT and UPDATE. Flag any insert policy whose WITH CHECK only checks auth.role() = 'authenticated' or auth.uid() IS NOT NULL on a table with an owner column (user_id, created_by, author_id, profile_id ...), any insert open to anon without an auth check, any insert into a membership table (team_members, workspace_members ...) that doesn't check the caller is an admin of that team, and any update whose WITH CHECK doesn't keep the owner. Rewrite each with TO authenticated and WITH CHECK ((select auth.uid()) = owner_column), use a security definer function for team admin checks, and write tests where user B tries to insert a row with user A's id.
How Leakcade checks this
Our insert-policy check reads your SQL migrations, finds tables with an owner column (by name or a reference to auth.users) and flags insert policies that only check for a login, inserts open to visitors without an auth check, login-only inserts on membership tables, and updates that let an owner hand a row to another user. Anonymous inserts into log, analytics and feedback tables are left alone because they're usually on purpose. Policies made only in the Supabase dashboard don't show up in migrations, and the report tells you to compare.
Membership tables get the high severity in our report.
Further reading
- PostgreSQL docs: CREATE POLICY
- Supabase docs: Row Level Security
- OWASP: Insecure Direct Object Reference Prevention
FAQ
What is the difference between using and with check in Supabase?
using filters the existing rows a query can read, update or delete. with check validates the new version of a row on insert and update. Insert policies only have with check.
How do I make users insert only their own rows in Supabase?
Create an insert policy for the authenticated role with with check ((select auth.uid()) = user_id), where user_id is the owner column.
Is default auth.uid() enough?
No. A default only applies when the insert leaves the column out. The client can send its own value, so the policy has to check it.
Why do I get infinite recursion in my team_members policy?
The policy queries the table it protects. Move the membership lookup into a security definer function and call that from the policy.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms