Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Supabase profiles table exposed: how to lock it down

FIX / PROFILES TABLE EXPOSED / 9 MIN READ

To fix an exposed Supabase profiles table, delete every policy that lets the anon role read private columns, move those columns to a protected table, and test the API without a login. Row Level Security (RLS) controls rows, not columns. A policy such as using (true) shares every column of every row. If the table has an email, a phone number, or a role, those values are public.

This guide shows how to confirm the exposure, how to fix it in order, and how to check what was read. It also shows how to design the table so that the problem does not return.

What does “profiles table exposed” mean?

It means that a caller without a login, or a caller with any account, can read rows of your profiles table that belong to other users. It also means that the caller can read columns that you did not plan to share.

The Supabase user management guide shows the common pattern. You create a public.profiles table with an id that references auth.users. A trigger copies data into the table when a user signs up. The same guide states that the Auth schema is not exposed in the auto-generated API. The auth.users table is therefore not the problem. Your copy of the data is the problem.

The guide also gives the grants for the example table. It grants select on public.profiles to anon. It grants select, insert, update, and delete to authenticated. It then enables RLS. These grants are correct only when RLS and its policies are correct. The grants make the table reachable through the Data API. The policies decide which rows a role can use.

Three mistakes cause most exposed profiles tables:

Mistake Result
RLS is off on the table Every role with a grant reads and writes every row
A policy uses using (true) for select Every caller reads every row and every column
The table holds private columns next to public ones A public policy also shares the private columns

The Supabase RLS guide states the first case plainly. A table in an exposed schema without RLS is readable and writable by any role with a grant on it.

AI tools often write the second mistake. You ask for a page that shows other users’ names. The tool adds a policy that lets everyone read the table. The page works. The tool does not check which columns the table holds.

How do I confirm that my profiles table is exposed?

Test the API the way an attacker does. Use only the public key. Do not use the service_role key, because that key skips RLS and gives a false result.

  1. Find the project URL and the anon key. Both are in your frontend code. See the Supabase anon key guide if you do not know where to look.
  2. Request the table without a login. Run this command:
curl -s "https://<project_ref>.supabase.co/rest/v1/profiles?select=*&limit=5" \
  -H "apikey: <ANON_KEY>" \
  -H "Authorization: Bearer <ANON_KEY>"
  1. Read the answer. An empty array [] means that no row is visible to anon. A list of rows means that the table is open. An error with the text permission denied means that the role has no grant. That result is safe.
  2. Look at the columns. Check each key in the JSON. Look for email, phone, role, is_admin, stripe_customer_id, address, and any token field.
  3. Repeat as a signed-in user. Sign in with a test account and use that session token in the Authorization header. Request select=* again. If you see rows of other users, the policies are too wide for logged-in users as well.
  4. Check the policies. Run this query in the SQL Editor:
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and tablename = 'profiles';
  1. Check the RLS flag. Run this query:
select relname, relrowsecurity
from pg_class
where oid = 'public.profiles'::regclass;

If relrowsecurity is false, RLS is off. If a policy has qual set to true, the policy has no filter.

  1. Check the other tables. Do the same test for users, accounts, customers, members, and any table with a user_id column. Names differ between projects. The problem does not.

Warning: Do not test a production table by running update or delete calls. A read test is enough to prove the exposure.

If the test returns private data, start the fix now.

What do I do first? Fix it now, in order of urgency

Do these steps in order. Steps 1 and 2 stop the leak. The later steps keep your app working.

Caution: Step 1 can break pages that show other users’ names or avatars. A broken page costs less than an open table. Restore the public data in step 4.

  1. Stop all reads from anon. Remove the open policy and enable RLS:
alter table public.profiles enable row level security;

drop policy "your open policy name" on public.profiles;

Use the policy names from your own pg_policies output. Policy names differ in each project. With RLS on and no policy, the table returns no rows to the API. The RLS guide states this behavior.

  1. Test again. Run the curl command from the confirm section. The result must be an empty array or an error.
  2. Add a policy for the owner. Each signed-in user can read and update only their own row:
create policy "owner can read own profile"
on public.profiles
for select
to authenticated
using ((select auth.uid()) = id);

create policy "owner can update own profile"
on public.profiles
for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);

The select wrapper around auth.uid() helps the query planner. The RLS guide recommends it for speed. Policies on the same operation combine with OR. An old open policy that you did not delete undoes the new one. See the Postgres policy documentation.

  1. Share only the public data. Create a separate table for data that all users may see. The next section shows how.
  2. Move the private columns. Put the email, the phone number, and similar fields in a protected table.
  3. Check the write side. Make sure that a user cannot change columns such as role or is_admin on their own row. See the section on writes below.
  4. Rotate secrets if the table held any. If a column held an API key or a token, rotate it at the vendor. The anon key does not need a change.
  5. Check the damage. Use the section on logs below.

How do I share public profile data and keep the rest private?

You have three safe designs. Pick the simplest one that fits your app.

Option A: Two tables

Keep public data in public.profiles. Keep private data in a second table. This is the design that the Supabase column-level security guide recommends for sensitive data. The guide advises RLS policies together with a dedicated table for data such as user roles.

create table public.profiles (
  id uuid primary key references auth.users (id) on delete cascade,
  display_name text,
  avatar_url text
);

create table public.profile_private (
  id uuid primary key references auth.users (id) on delete cascade,
  email text,
  phone text
);

alter table public.profiles enable row level security;
alter table public.profile_private enable row level security;

create policy "public profile data is readable"
on public.profiles for select to anon, authenticated
using (true);

create policy "owner reads private data"
on public.profile_private for select to authenticated
using ((select auth.uid()) = id);

The using (true) policy is correct here. The table holds only data that you want to share. The private table has no policy for anon, so the API returns nothing to anonymous callers.

If you share only the profiles of some users, replace true with a real condition. For example, add an is_public column and use using (is_public).

Option B: Column privileges

Column privileges can hide a column from a role. The column-level security guide shows the syntax with revoke and grant. The pattern is to revoke the table privilege and grant the allowed columns:

revoke select on table public.profiles from anon, authenticated;
grant select (id, display_name, avatar_url) on table public.profiles to anon, authenticated;

After this change, a request that asks for select=* can fail with a permission error. Your code must name the columns. This option is harder to maintain, because each new column needs a deliberate grant. Use it only if you cannot split the table.

Option C: A view

A view can expose chosen columns. A view bypasses RLS by default, because it runs with the rights of its owner. The RLS guide states this. On Postgres 15 and later, set security_invoker = true so that the view obeys the policies of the underlying table. Then check the result with the curl test. A view without this setting is a common source of new leaks.

Option Strength Weakness
Two tables Clear and hard to break You must keep two rows in step
Column privileges One table Easy to forget a grant
View Simple reads Bypasses RLS if you omit security_invoker

Why must I check the write side?

An exposed profiles table is often also writable. If the update policy checks only the row and not the columns, a user can change any column in their own row. A user can then set role to admin.

Use these controls:

  1. Keep roles out of the profiles table. Store roles in a separate table that no client can write. The Supabase column guide recommends this design.
  2. Revoke update on sensitive columns. Use revoke update (role) on table public.profiles from authenticated or grant only the allowed columns.
  3. Add with check to each update policy. The using clause selects the existing rows. The with check clause validates the new row.
  4. Set the id on the server. Do not let a client choose the id of an insert. Use a trigger that runs when the user signs up.

The trigger from the Supabase guide uses security definer set search_path = ''. Keep the search_path setting. Without it, a function with elevated rights can be tricked into running other code.

How do I check the damage?

You need to know who read the table while it was open. The check has limits, because not every read leaves a record.

  1. Write down the exposure window. Use the date of the migration or the commit that added the open policy. If you do not know it, use the creation date of the table.
  2. Open the API logs. The Supabase logs guide lists API Gateway and PostgREST as log sources. Open Logs in the Dashboard.
  3. Search for requests to the table. Look for /rest/v1/profiles with the method GET. Find requests that use select=* or that read many rows.
  4. Check who sent the requests. A normal user reads one row, or a short list. A large read with no login, from one address, can mean a copy of the table.
  5. Check the retention. The logs guide states that retention depends on your plan. If the exposure window is longer, you cannot prove that no one read the table.
  6. List the columns that were exposed. Write down each private field. This list decides who you must tell and which secrets you must rotate.
  7. Check the app code. Search your repository for from("profiles") and select("*"). A new design must not break these calls without a test.

If the table held email addresses or phone numbers, assume that a copy exists. Warn your users about phishing that uses these data. Ask a legal adviser whether you must report the exposure.

How do I stop the next exposed table?

Use these controls in each project.

  1. Enable RLS when you create a table. Put enable row level security in the same migration as create table.
  2. Split public and private data from the start. Never put an email or a phone number next to a display name in a table that you share.
  3. Name the columns in each query. Use select("id, display_name, avatar_url"). Do not use select("*").
  4. Never use using (true) without a review. Check the columns of the table first. Add the check to your code review.
  5. Test each table with the public key. Add the curl test from this guide to your release process.
  6. Scan the live app. A scan finds open tables that you did not test. See the Supabase RLS guide for more patterns and the production security checklist for a release check.

When should I get help?

Get help in these cases:

  • The table held government IDs, health data, payment data, or other sensitive data.
  • The logs show large reads from unknown addresses during the exposure window.
  • You cannot tell which policy allows which access. Send the pg_policies output to an experienced reviewer.
  • The table holds roles or permissions, and you cannot be sure that no user changed their own role. Review the role column for users that you do not know.
  • Your log retention is shorter than the exposure window.

After the fix, run the curl test again with the anon key and with a test user session. The anonymous test must return no private data. The user test must return only the row of that user, plus the public data that you chose to share.

Common questions

Is the Supabase auth.users table exposed to the API?
No. The Auth schema is not exposed in the auto-generated API. The leak happens when your own profiles table copies the email or other private data.
Is it safe to let everyone read a profiles table?
Only if the table holds public data. A display name and an avatar are public data. An email, a phone number, or a role is not.
Does RLS hide single columns?
No. A policy allows or blocks whole rows. To hide a column, use column privileges, a separate table, or a view.
Can the anon key read my profiles table?
Yes, if a policy or a missing RLS setting allows the anon role to read it. Test the table with the anon key to confirm.
Do I need to rotate keys after I fix the table?
Not for this problem. The anon key is public by design. Rotate a key only if the table held a secret, such as an API key or a token.

Test your profiles table

A VibeEval scan calls your Supabase API with the public key and reports profile tables that return emails, roles, or other private columns.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →