← All notes

Field note

auth.uid() is not null is not RLS

Supabase's RLS examples compare auth.uid() to an owner column. A policy that only checks auth.uid() is not null, or auth.role() = 'authenticated', admits every signed-in user.

23 Oct 20267 min read1436 words

Row Level Security is on, the policy editor is green, and every signed-in user can still read every row. That happens when the expression checks that a session exists and never checks which session it is. auth.uid() is not null is that expression. So is auth.role() = 'authenticated'.

Supabase's RLS guide does not use either of those as the way to protect a private table. The policies it shows compare the user id to a column. This note is that comparison, the login-only patterns that fail it, and the read-only check that names the table and command when a catalog row matches.

The anon key post is the other half: grants, RLS off, and what the publishable key can select when no policy applies. This post assumes RLS is already enabled and a policy is already attached. The free scan reports the login-only case as "RLS policy only checks that the user is logged in."

What the docs actually compare

Supabase's Row Level Security guide says auth.uid() returns the id of the user making the request. The policies in that guide then use the id:

sql

create policy "Users read their own rows"
on public.notes
for select
to authenticated
using ( (select auth.uid()) = user_id );

The same page uses that shape for insert (with check), for update (both using and with check), and for delete. The (select auth.uid()) form is the one they wrap so the planner can cache the call per statement. The comparison is the part that makes it row level. user_id in the example is a column on that table. If your table uses owner_id or created_by, the comparison uses that column. The guide does not tell you to add a column so the example compiles.

Two roles sit under those policies. anon is a request with the publishable key and no user JWT. authenticated is a request with a user JWT. A policy to authenticated does not run for the anon role. It does run for every JWT that Supabase Auth has mapped to authenticated.

The same guide draws a line that vibe-coded policies skip. An anonymous sign-in is not the anon role. An anonymous user assumes the authenticated role. You tell them apart with the is_anonymous claim on the JWT, not with auth.uid() is not null. That function returns an id for an anonymous user too. A policy that only asks whether the id is present treats a throwaway session and a real account as the same person, and then lets that person see every row the policy covers.

Login-only expressions

These are session checks. Each one is true for every signed-in user that reaches the policy:

  • auth.uid() is not null
  • (select auth.uid()) is not null
  • auth.role() = 'authenticated'
  • (select auth.role()) = 'authenticated'
  • auth.jwt() is not null
  • (select auth.jwt()) is not null
  • auth.jwt() ->> 'role' = 'authenticated'
  • (auth.jwt() ->> 'role') = 'authenticated'

USING filters rows on select, update, and delete. WITH CHECK filters rows on insert and update. A login-only clause in either place is the bug. Insert is the one people miss, because the Dashboard template "Enable insert for authenticated users only" often saves a check that means "any session," and the table then accepts a row whose owner column is someone else's id, or null.

AND does not save a policy that is only these atoms. auth.uid() is not null and auth.role() = 'authenticated' is still true for every signed-in user. Both sides pass together.

OR is worse. user_id = auth.uid() or auth.role() = 'authenticated' keeps the owner comparison and then adds a branch that is true for everyone signed in. The owner comparison never gets a chance to deny. One login-only branch is enough.

An owner comparison by itself is the guide's pattern: (select auth.uid()) = user_id. That is not this bug. AND between a login check and an owner comparison is also not this bug, because the owner comparison still has to pass. The login check is redundant there. It is not the hole.

using (true) is a different hole. It does not ask about a session at all. The RLS guide's warning about to anon using (true) is that every unauthenticated visitor with a grant can read the rows. Do not "fix" a login-only policy by replacing it with true.

Grants still run first

The RLS guide's danger callout is the one to read before rewriting policies. A table in an exposed schema without RLS is readable and writable by any role that has a grant. Enable RLS on every exposed table. On projects that still grant anon and authenticated by default, revoke those grants. Adding a policy does not remove a grant.

Postgres checks the grant before the policy. A login-only policy on authenticated matters only after authenticated can already SELECT or INSERT. Revoking the grant closes the role. The policy is what you still need for the roles you keep. The anon key post walks the grant half with curl against the Data API. Do that first if the REST response is a full table with no JWT at all. This post is the case where a JWT is required and the wrong JWT still works.

What the scan stores

When a scan has the project's publishable key, it sends one GET to pg_policies through the Data API. The query asks for schema, table, policy name, command, and the two expressions. The request uses that anon key. It does not use the secret key. A non-200 response, or a body that is not valid JSON, produces no finding. A catalog that is not exposed to anon stays quiet, which is the right outcome. Failing closed is not a clean bill of health. It means this particular read did not see the policies.

When a row's qual or with_check matches only the login checks above, the finding is "RLS policy only checks that the user is logged in." The preview names the table and the command, for example profiles SELECT or notes INSERT. It does not paste the expression into the report, and it does not invent a column. The policy name from the Dashboard can contain spaces, so the finding does not depend on that name being a SQL identifier.

The check is strict about what counts. true and false are not reported under this title. An owner equality is not. An AND of a login check plus an owner equality is not. An OR that includes a login check is. A table or schema name that is not a plain identifier is dropped rather than guessed.

You can run the same read against your own project. A 401 or a 404 means the catalog is closed to the publishable key. Rows mean you can read qual and with_check yourself.

bash

curl -sS \
  "$SUPABASE_URL/rest/v1/pg_policies?select=schemaname,tablename,policyname,cmd,qual,with_check&limit=20" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

Use the publishable key. The secret key bypasses the question you are asking.

Replace the expression, using a column you already have

Open the table and the command named in the finding. Replace the login-only expression with a comparison to the owner column that table already has. Supabase's guide uses user_id. Yours might differ. Do not add a column to match the example, and do not copy <owner_column> into production as if it were a real name. The fix snippet on the finding says the same thing, because the scan cannot see your column list from this read.

sql

-- Replace user_id if this table already uses a different owner column.
create policy "Users read their own rows"
on public.notes
for select
to authenticated
using ( (select auth.uid()) = user_id );

Writes need with check as well as using, or an insert can store a row the author does not own and an update can move a row onto someone else. The guide's update policy sets both to the same comparison.

Rows that are meant to be public get their own policy, with the role and the true check written on purpose. They do not get auth.uid() is not null as a stand-in. That expression looks like a restriction and behaves like a public read for anyone who can sign up.

If anonymous sign-in is on, a real owner check is still the first fix. The auth posture post covers the settings document and the is_anonymous claim. A restrictive policy can require (auth.jwt() ->> 'is_anonymous') is not true on writes when throwaway sessions must not insert. That claim check is extra. It does not replace the owner comparison.

After you save the policy, run the scan again on the verified domain. The finding names the table and command that still match. A policy you have not edited will still match. The free scan is that re-check against the URL, and the fix prompt on the finding is the same steps in a form you can paste into Lovable, Cursor, or Claude.