guide

Supabase says “permission denied for table” (error 42501): why it started, and the grants that fix it

What the error means, why new tables now need an explicit grant, the SQL for tables, sequences, functions and views, and the curl test as a visitor and as a signed-in user.

By Sameem Amjad

The short answer

The error means the Postgres role your request ran as, anon for a visitor or authenticated for a signed-in user, holds no privilege on that table. Postgres checks privileges before it reads any row-level security (RLS) policy, so no policy will fix it. Supabase stopped granting those roles access to new tables in public automatically: for projects created since 30 May 2026, and for existing projects from 30 October 2026. A table created this week therefore needs an explicit grant while last year's tables keep working, so run grant select on public.profiles to authenticated; plus the writes your policies allow, keep RLS on, and the request succeeds.

Test it in 10 seconds

Call the table through the Data API with your publishable key and no user. A legacy anon key goes in the same header:

curl -i 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*' \
  -H "apikey: YOUR_PUBLISHABLE_KEY"

Red is a 401 or 403 with the error from the title as the body. Supabase's docs print it like this, with your table's name in place of profiles:

{
  "code": "42501",
  "message": "permission denied for table profiles",
  "hint": "Grant the required privileges to the current role with: GRANT SELECT ON public.profiles TO anon;"
}

Green is []: the grant exists and RLS is withholding rows from a visitor, which is right for private data. Rows are green only for a table that is meant to be public. Rows from a table of user data are the problem my guide to Supabase's “RLS disabled in public” warning covers. The hint suggests a grant for the role that made the request, so a visitor's request gets TO anon. Read the section on roles before you paste it; anon is the wrong role for user data.

Why it started on 30 May, or on 30 October

Postgres runs two checks before a request touches a table. The grant decides whether the role may run the operation at all; the policy decides which rows that operation applies to. A missing grant fails with code 42501 before any policy runs. That is why the policy you wrote changed nothing, and why Supabase's own advice is to check the grants before you change a policy.

Until this year, every table created in public was granted select, insert, update and delete to anon, authenticated and service_role the moment it existed. Each new table was reachable through the Data API on creation, and RLS was the only thing between a visitor and your rows. On 28 April 2026 Supabase announced it was reversing that default. Projects created from that day could untick “Automatically expose new tables”. From 30 May 2026 that became the default for new projects, rolled out over a few weeks. From 30 October 2026 Supabase is applying it to existing projects. Existing tables keep their grants and stay reachable, and RLS behaviour is unchanged. Code that talks to Postgres over a direct connection (psql, an ORM, a server with the connection string) is unaffected. supabase-js, the REST endpoint and the GraphQL endpoint go through the Data API and are affected.

The apps that hit the error have tables created by something that never learned to grant: a migration written by Lovable, Bolt or Cursor, a CLI script, a call to the Management API. Supabase's advice for AI tools is to update the tool's system prompt or install its agent skill, which includes the grants step. It also notes that an agent can correct itself from the hint in the error. Until then every new table fails on its first request while the old ones carry on, which makes the symptom look like a bug in your latest change rather than a change in the platform.

Which privileges your project grants on new objects

Defaults differ by project, by its creation date and by its Data API settings. This query, from Supabase's docs, lists what new tables, sequences and functions created by the postgres role in public grant to the three API roles. Run it in the SQL editor:

select
  case d.defaclobjtype
    when 'r' then 'tables'
    when 'S' then 'sequences'
    when 'f' then 'functions'
  end as object_type,
  a.grantee::regrole as role,
  string_agg(a.privilege_type, ', ' order by a.privilege_type) as privileges
from pg_default_acl d
cross join lateral aclexplode(d.defaclacl) a
where d.defaclrole = 'postgres'::regrole
  and d.defaclnamespace = 'public'::regnamespace
  and a.grantee in ('anon'::regrole, 'authenticated'::regrole, 'service_role'::regrole)
group by 1, 2
order by 1, 2;

No rows means new objects grant nothing to anon, authenticated or service_role until you grant it: the new default. A project still on the old default prints a row per role and object type, for example:

 object_type |     role      |           privileges
-------------+---------------+--------------------------------
 tables      | anon          | DELETE, INSERT, SELECT, UPDATE
 tables      | authenticated | DELETE, INSERT, SELECT, UPDATE

The query leaves out execute, which every new function receives through PUBLIC whatever your defaults say. The dashboard has a switch for the same thing, “Automatically expose new tables”, in the Data API settings. Turning it off revokes the privileges the Data API uses but leaves truncate, references, trigger and maintain on new tables and update on new sequences, so the SQL route is cleaner. I can't see your project from here, and the rollout to existing projects is gradual. The day yours switched is the one in Supabase's emails to the project's owners and admins, not the date above. The query is the only thing that tells you for certain.

The fix: grant each role what its policies describe

Three roles, one rule each. Every visitor with your publishable key runs as anon, which means everyone who opens the site: select on public data, nothing on data that belongs to a user. Signed-in users run as authenticated, which gets the operations your app offers them, and RLS decides which rows. Your secret key runs as service_role. It bypasses RLS but not grants, so an Edge Function or a webhook writing to a new table with the secret key gets the same 42501 until you grant it.

Put the grants in the same migration as the table's RLS and policies, so the table ships with both or with neither, and supabase db reset replays them. A grant clicked in the dashboard is not replayed. For a table where each user owns one row:

-- supabase/migrations/20261030120000_profiles_access.sql

-- 1. Grants: which roles may touch the table at all
grant select, insert, update on public.profiles to authenticated;
grant select, insert, update, delete on public.profiles to service_role;

-- 2. RLS: which rows each role may see or change
alter table public.profiles enable row level security;

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

create policy "Users can create their own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );

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

A table everyone may read, such as a product list, gets select for both client roles and a policy that says so. No write grant, so only your server can change it:

grant select on public.products to anon, authenticated;

create policy "Products are readable by everyone"
on public.products for select
to anon, authenticated
using ( true );

If Lovable, Bolt or Cursor writes your migrations, paste the grant lines into the chat with the table name, and ask for grants in the same migration as the policies from now on. Otherwise the next table fails the same way. Lovable's built-in backend, which the product calls Cloud, is a Supabase project that Lovable provisions and manages. It does not appear in your Supabase dashboard and has no SQL editor, so the grants go in as a migration through Lovable. The backend tools under the project's More menu show the tables and the logs. Supabase's dates apply to that project like any other unless Lovable created it with the expose setting on, which I cannot see from outside; the symptom and the fix are the same.

The second 42501: the sequence behind a serial column

Grant insert and the insert can fail again with a new noun: permission denied for sequence profiles_id_seq. A column declared serial or bigserial takes its next value from a sequence, and the inserting role needs usage on that sequence too. Under the old defaults, sequences in public got usage, select and update automatically; under the new default they get nothing. Columns declared generated by default as identity do not need the grant. On Postgres 18, an identity column accepts inserts with only the table grant, while a serial column next to it fails until the sequence is granted.

grant usage, select on sequence public.profiles_id_seq to authenticated;

One more trap in the same family. supabase-js .insert(...).select() reads the new row back, which Postgres treats as a returning clause. The role then needs select on the table as well as insert, plus a select policy that matches the new row. Without the grant the insert fails with permission denied for table profiles. With the grant and no matching policy it fails with new row violates row-level security policy for table "profiles", which carries the same code 42501 but comes from RLS, not from the grant. An .insert() without .select() needs the insert grant alone.

Schemas, functions and views

The same code arrives with other nouns, and each one names the object that lacks the privilege.

  • permission denied for schema myschema: the role has no usage on the schema, which Postgres checks before the table. A custom schema needs grant usage on schema myschema to anon, authenticated, service_role; and an entry under “Exposed schemas” in the project's API settings. auth and vault are off limits to the API roles by design. Read them through a security definer function in a schema that is not exposed, or not at all.
  • permission denied for function profile_count: the role has no execute. Postgres grants execute to PUBLIC on every new function, so this appears after someone revoked it, as Supabase's hardening steps do. Grant it per function to the roles that call it, including the helper functions your policies call: grant execute on function public.profile_count() to authenticated;.
  • permission denied for table profiles when you queried a view: the view has security_invoker = true, the right setting on Postgres 15 and later, so the caller's grants and policies apply to the tables underneath. The caller needs select on the view and on each table. A view without that option runs with its owner's privileges and hands out every row the table would withhold, so do not fix this one by dropping the option.
  • permission denied for table customers on select * when single columns were granted: * expands to the columns the role may not read. Name the columns, or grant the table.
create view public.profile_names
with (security_invoker = true) as
select id, full_name from public.profiles;

grant select on public.profile_names to authenticated;

What else changes once the grant is in

  • A grant without RLS is an open table: any role with the grant can read and write every row. Enable RLS and write the policies in the same change; the grant is the door, the policy decides who gets through it.
  • Old tables keep working, which hides the problem until someone adds a table. Supabase's Security Advisor and the Data API exposure badge in the Table Editor list the tables to review; before the rollout the advisor also showed the remediation SQL.
  • The GraphQL endpoint follows the same grants as REST, since both are the Data API.
  • If you develop with supabase start, the CLI's auto_expose_new_tables setting was a temporary aid that Supabase's maintainers scheduled for removal on 30 October 2026. Explicit grants in migrations are the durable path.

Test it as a visitor, then as a user

Three requests prove the grant held and the policies do their job. Use a test account you created for this, never a customer's:

# 1. As a visitor: a private table answers [] or the 42501 body, never rows
curl -s 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=id,full_name' \
  -H "apikey: YOUR_PUBLISHABLE_KEY"

# 2. Sign the test user in and read access_token from the reply
curl -s 'https://YOUR-PROJECT.supabase.co/auth/v1/token?grant_type=password' \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"YOUR_TEST_PASSWORD"}'

# 3. As that user: only their own row comes back
curl -s 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=id,full_name' \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer USER_ACCESS_TOKEN"

Step 1 returns [] or the 42501 body for a private table; rows mean the table is open to visitors. Step 2 returns JSON with an access_token, the user's JWT. The token endpoint accepts a password grant and is rate limited to 150 requests per five minutes by default, so do not loop it. Step 3 returns the user's own row, such as [{"id":1,"full_name":"Test User"}]. An empty [] there means the grant is in but no policy matches the row, nearly always a user_id that is not the signed-in user's auth.uid(); the 42501 body means the grant is still missing for authenticated. If you'd rather not call the token endpoint, sign in to your app and copy the Authorization header from any request to /rest/v1/ in the browser's Network tab; it carries the same token.

To keep the check, Supabase's RLS guide shows a pgTAP file per table under supabase/tests/ that sets local role anon, expects 42501 on the writes a visitor must not make, and runs with supabase test db. A test that fails the day someone forgets a grant beats a customer finding out.

When the grant isn't the only problem

An app that hit this error has usually spent its life on the old default: every earlier table granted to anon on creation and protected by whatever policies the builder happened to write. Often there is a Stripe webhook or an Edge Function that trusts the browser as well. Fixing one grant gets this week's table working; it says nothing about what the other tables let a visitor do. If you'd rather have the whole app checked before real users find out, that's what my launch-readiness audit covers, the first step of my Finish & ship service.

Chat on WhatsApp