← All posts
·7 min read·GuardLayer

Fix: permission denied for schema public (Supabase)

SupabasePostgresGrantsPrismaRLS

ERROR: 42501: permission denied for schema public means the calling role — anon or authenticated — lost USAGE on the public schema. It is not an RLS failure: the schema check runs before table privileges and before any policy, so your policies never execute. Restore it with grant usage on schema public to anon, authenticated; and then grant only the table privileges each role genuinely needs.

The reliable tell is timing. Everything worked, then someone ran a migration, and now every table in the project is unreachable at once. A broken RLS policy affects one table. A missing schema grant affects all of them.

Discussion #14393 is titled "permission denied for schema public after migration," and a Supabase collaborator names the culprit directly: ORM migrations "tend to mess up grants on the schemas so Supabase can no longer access."

Postgres checks three things, in order

This is the whole post, really. Before a single row is considered, Postgres asks:

  1. Does the role have USAGE on the schema? No → permission denied for schema public.
  2. Does the role have the privilege on the table? No → permission denied for table X.
  3. Do the RLS policies allow this row? No → zero rows, or new row violates row-level security policy on a write.

Only step 3 involves RLS. Steps 1 and 2 are plain Postgres grants, and they fail before the policy engine is consulted. That's why adding another policy never fixes this error, and why the error message says nothing about security policies.

Gate 1 is also the coarsest. Lose it and the role can't see anything in the schema — not tables, not views, not functions. PostgREST can't even introspect to build its schema cache, so you'll often see the failure as a blanket API outage rather than as a query error.

Why does Supabase say "permission denied for schema public"?

Because something revoked USAGE from the API roles, and there are only a few things that do.

1. A Prisma or Drizzle migration recreated the schema. prisma migrate reset, a --force-reset, or any migration containing DROP SCHEMA public CASCADE; CREATE SCHEMA public; gives you a brand-new schema owned by the migration role, carrying default Postgres grants — which do not include Supabase's anon, authenticated, and service_role. Supabase provisions those grants when the project is created; nothing re-applies them afterwards. This is a different problem from ORM connections bypassing RLS entirely, but it comes from the same root cause: the ORM connects as a privileged role and has no idea the API roles exist.

2. A hardening script over-revoked. REVOKE ALL ON SCHEMA public FROM PUBLIC is standard Postgres advice and mostly harmless. REVOKE ALL ON SCHEMA public FROM anon, authenticated is not — that's the API, and it's an easy line to copy from generic Postgres guidance that knows nothing about Supabase's role model.

3. Postgres 15 defaults, on a self-hosted or restored database. Since Postgres 15 the public schema is owned by pg_database_owner and no longer grants CREATE to PUBLIC. Restoring a dump into a fresh cluster, or self-hosting, can land you with a public schema that never had the Supabase grants in the first place.

4. You're querying a schema that isn't exposed. If the error names a schema other than public — say permission denied for schema app — the fix isn't only grants. The schema also has to be listed under Exposed Schemas in Settings → API before PostgREST will touch it.

The fix

Run this in the SQL Editor, or better, commit it as a migration so it survives the next reset:

-- Schema-level access: gate 1.
grant usage on schema public to anon, authenticated, service_role;

-- Table-level access: gate 2. Grant per role, per table, per privilege.
grant select on public.posts to anon;
grant select, insert, update, delete on public.posts to authenticated;

-- Gate 3 is still yours to write.
alter table public.posts enable row level security;

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

create policy "authors manage their own posts"
  on public.posts for all
  to authenticated
  using (auth.uid() = author_id)
  with check (auth.uid() = author_id);

If your ORM is going to keep recreating the schema, make the grants part of the ORM's own migration history so they get re-applied every time, and add default privileges so tables created later inherit them:

alter default privileges in schema public
  grant select on tables to anon;

alter default privileges in schema public
  grant select, insert, update, delete on tables to authenticated;

alter default privileges in schema public
  grant usage, select on sequences to anon, authenticated;

One caveat on ALTER DEFAULT PRIVILEGES: it applies only to objects created by the role that runs it, and only from that point forward. Run it as the same role your migrations use, or it will silently do nothing.

The copy-paste fix that opens your database

Search this error and you will find a four-line snippet that ends the pain instantly: grant USAGE on the schema, then ALL PRIVILEGES ON ALL TABLES to anon, authenticated, and service_role. It works. It also hands every anonymous visitor holding your publishable anon key full read and write access to every table in the schema.

Here's the same shape as a real migration, and what a scan says about it:

guardlayer scan · supabase/migrations/20260831120000_restore_grants.sqlLive engine output
Passed with warnings
84/100 · B
  • Warningsupabase/migrations/20260831120000_restore_grants.sql:7

    Table granted to the anon role without RLS

    Enable RLS on the table (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies, or revoke the grant from anon. Only keep an anon grant for genuinely public data that is still RLS-protected.
  • Warningsupabase/migrations/20260831120000_restore_grants.sql:8

    Table granted to the anon role without RLS

    Enable RLS on the table (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies, or revoke the grant from anon. Only keep an anon grant for genuinely public data that is still RLS-protected.

Two grants, two findings. anon is the role attached to the key that ships in your JavaScript bundle — it is, by design, public information that anyone can extract. Granting it DELETE on profiles means the only thing standing between a stranger and your user table is an RLS policy, and if this migration ran because a reset wiped the schema, the policies were probably wiped with it.

Since the 2026 Data API grant change, grants are the mechanism that exposes a table to the REST API at all. That makes them more load-bearing than they used to be, not less. Two rules keep this safe:

  • Grant to authenticated, not anon, unless the data is genuinely public.
  • Every table you grant to either role has RLS enabled and at least one scoped policy — never using (true) on a write path.

service_role is a separate matter: it bypasses RLS entirely, which is fine because it never leaves your server. If a service_role grant is the one that fixed your problem, check why a browser request was ever running as service_role.

Quick self-check

-- The direct answer: does each API role have USAGE right now?
select has_schema_privilege('anon', 'public', 'usage') as anon_usage,
       has_schema_privilege('authenticated', 'public', 'usage') as auth_usage,
       has_schema_privilege('service_role', 'public', 'usage') as service_usage;

-- The raw ACL on the schema itself, if you want to see who was granted what.
-- (Note: nspacl, not pg_default_acl — default privileges describe FUTURE
-- objects and tell you nothing about current schema access.)
select nspname, nspowner::regrole as owner, nspacl
from pg_namespace
where nspname = 'public';

-- Which tables is anon actually allowed to touch?
select table_name, privilege_type
from information_schema.role_table_grants
where grantee = 'anon' and table_schema = 'public'
order by table_name;

If anon_usage is false, that's your error. If it's true and the third query returns a long list of tables with INSERT, UPDATE, and DELETE, you've already applied the dangerous fix — cross-check each of those against a table that actually has RLS enabled before you do anything else.

FAQ

Is this the same as permission denied for table? No. Same SQLSTATE (42501), different gate. Schema USAGE is checked first and affects every object in the schema; table privileges are checked second and affect one table.

Will enabling RLS fix it? No, and it can't. RLS is evaluated after both grant checks pass. With no schema USAGE, the policy engine is never reached.

Why did it start after I ran prisma migrate dev? Because that command can drop and recreate the public schema. The new schema has default Postgres grants, and Supabase's API roles aren't in them.

Does service_role hit this too? It can. If your server-side calls fail with the same error, service_role lost USAGE as well. It's a superuser-adjacent role in Supabase's model, but it is still subject to schema grants.

Should I grant to postgres as well? Include it in the schema grant for safety — grant usage on schema public to postgres, anon, authenticated, service_role; — since a reset can leave even the owner role in an odd state. Just don't extend the same reflex to blanket table privileges for anon.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.