← All posts
·8 min read·GuardLayer

Fix: permission denied for schema auth (Supabase 42501)

SupabaseAuthPostgresGrantsSecurity

ERROR: 42501: permission denied for schema auth means a role tried to do something in Supabase's auth schema that it isn't allowed to: usually a migration creating a function or table there (blocked since April 2025), an ORM trying to manage auth.users, or a custom role calling auth.uid(). Its sibling, permission denied for table users, means an API role (anon or authenticated) tried to read auth.users, which it can never do. Don't grant your way in. Keep your objects in your own schema, read the user from the JWT with auth.jwt(), and when you really need a column from auth.users, use a small SECURITY DEFINER function with a pinned search_path.

Both errors are Postgres doing what Supabase set it up to do. auth holds every user's email, phone number, password hash and metadata, and its owner is supabase_admin, not you. Nothing you control can open it, and nothing should.

Who can do what in the auth schema

Supabase's own init script and its knowledge base spell out the setup:

RoleUSAGE on authRead auth.usersCreate objects in auth
supabase_admin, supabase_auth_adminYesYesYes (they own it)
postgres (you, in the SQL Editor and migrations)YesYesNo, since April 21, 2025
anon, authenticated, service_roleYesNoNo
A custom role you createdNoNoNo

That table explains which message you get:

  • No USAGE on the schema → permission denied for schema auth. That's a custom role.
  • No CREATE on the schema → permission denied for schema auth. That's postgres running a migration that puts something in auth.
  • USAGE but no table privilege → permission denied for table users. That's anon or authenticated reaching auth.users through a policy, a view, or a function that runs as the caller.

Postgres checks the schema before the table, and both before RLS, so no policy you write changes any of this. permission denied for schema public is the same first gate failing on your own schema, with a different fix.

Cause 1: a migration creates something in auth

On April 21, 2025, Supabase restricted the auth, storage and realtime schemas. Since then, postgres can't create tables or functions in them, can't create indexes on their tables, and can't drop anything. So an older tutorial's helper fails the moment you run it:

-- Fails: permission denied for schema auth
create function auth.is_admin() returns boolean ...

Fix: create it in a schema you own. A private schema keeps it off the Data API:

create schema if not exists private;

create or replace function private.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select coalesce(
    (select is_admin from public.profiles where id = (select auth.uid())),
    false
  );
$$;

Then update every reference to it (auth.is_admin() → private.is_admin()) in your policies. What's still allowed in auth: foreign keys to auth.users, and triggers and RLS policies on auth.users. The classic on_auth_user_created trigger still works. Its function just has to live in your schema, as SECURITY DEFINER with a pinned search_path.

The storage version of this restriction is must be owner of table objects.

Cause 2: your ORM thinks it owns auth.users

Drizzle and Prisma generate migrations from the tables they know about. Declare auth.users as an ordinary table in your schema file and the next migration tries to create or alter it, and fails with this error. Drizzle ships a ready-made reference for exactly this: import authUsers from drizzle-orm/supabase and point your foreign keys at it instead of declaring your own pgSchema("auth"). With Prisma, keep auth out of the schemas it manages, and let your own tables reference auth.users only through a foreign key written in SQL.

Cause 3: a custom role calls auth.uid()

auth.uid() lives in the auth schema, and custom roles have no USAGE there. On hosted Supabase, grant usage on schema auth to my_role seems to succeed but changes nothing, because postgres holds that privilege without the grant option. Discussion #51357 hits exactly this.

Supabase's recommended fix is a wrapper owned by postgres that the custom role is allowed to execute:

create or replace function private.current_user_id()
returns uuid
language sql
stable
security definer
set search_path = ''
as $$
  select auth.uid();
$$;

revoke all on function private.current_user_id() from public;
grant usage on schema private to my_role;
grant execute on function private.current_user_id() to my_role;

Cause 4: "permission denied for table users"

This one comes from your own SQL running as the caller. The most common shape is a policy that looks up the user's email:

-- Fails for every API request: authenticated can't read auth.users
create policy "recipients read orders sent to them"
  on public.orders for select
  to authenticated
  using (
    (select email from auth.users where id = auth.uid()) = recipient_email
  );

Policies run as the role making the request, so the subquery runs as authenticated, which has no privilege on auth.users. The same goes for an invoker view over auth.users, and for a trigger function without SECURITY DEFINER that reads it.

Fix 1: read it from the JWT. The signed-in user's email is already in their token, so nothing has to touch auth.users:

create policy "recipients read orders sent to them"
  on public.orders for select
  to authenticated
  using ((select auth.jwt() ->> 'email') = recipient_email);

Two caveats. The token carries the email from when it was issued, so a changed address shows up only after a refresh. And an email check only proves something if your project confirms addresses before sign-in.

Fix 2: a definer function, done properly. When you need something the JWT doesn't carry, read auth.users from a function that runs as its owner, in a schema the Data API doesn't expose:

create schema if not exists private;

create or replace function private.current_user_email()
returns text
language sql
stable
security definer
set search_path = ''
as $$
  select email from auth.users where id = (select auth.uid());
$$;

revoke all on function private.current_user_email() from public, anon, authenticated;
grant usage on schema private to authenticated;
grant execute on function private.current_user_email() to authenticated;

set search_path = '' stops anyone from hijacking an unqualified name while the function runs with its owner's rights. The revoke matters because EXECUTE defaults to PUBLIC in Postgres.

If what you actually wanted was user names and avatars in your UI, don't read auth.users at all. Use a profiles table, as covered here.

The fixes that leak your users

When this error shows up, the snippets that come up first make it go away by giving up the protection:

  • grant select on auth.users to authenticated. Even where it runs, it gives every signed-in user every row and every column: emails, phone numbers, metadata and password hashes. RLS filters rows, not columns, so no policy can hide the password hash from a row the user is allowed to see.
  • A public view over auth.users. It runs as postgres, skips RLS, and hands every email to anyone holding your anon key. More on that one here.
  • A definer function in public without a pinned search_path. That's the sample below:
guardlayer scan · supabase/migrations/20261102090000_shared_orders_policy.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20261102090000_shared_orders_policy.sql:3

    SECURITY DEFINER function without a fixed search_path

    Pin the search_path on the function: add `set search_path = ''` to the definition (or `ALTER FUNCTION <name> SET search_path = '';`) and fully qualify every object reference inside it, e.g. public.profiles instead of profiles.

The finding is the missing search_path. The function has a second problem: it sits in public, so it's also exposed as /rest/v1/rpc/current_user_email and callable by anyone, because EXECUTE defaults to PUBLIC. For an anonymous caller auth.uid() is null, so this one returns nothing. Add a parameter, like user_email(uid uuid), and the same shape returns anyone's email to anyone. Here's why that default bites.

Quick self-check

Run these in the SQL Editor. They only read the catalog.

-- Policies that read auth.users directly (they fail for API roles).
select schemaname, tablename, policyname
from pg_policies
where coalesce(qual, '') ilike '%auth.users%'
   or coalesce(with_check, '') ilike '%auth.users%';

-- Your functions that read auth.users, and who can call them.
select n.nspname as schema,
       p.proname as function,
       p.prosecdef as security_definer,
       p.proconfig as settings,
       has_function_privilege('anon', p.oid, 'execute') as anon_can_call
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname in ('public', 'private')
  and p.prosrc ilike '%auth.users%';

In the first query, every row is a policy to rewrite with auth.jwt() or a private definer function. In the second, a row with security_definer = true and no search_path in settings needs set search_path = '', and one in public with anon_can_call = true needs its EXECUTE revoked or a move to private.

To check a whole project for definer functions anyone can call, and for the other holes these workarounds open, run Live check. It's read-only and gives you the SQL fix for each finding.

FAQ

Can I grant the anon or authenticated role access to auth.users? Don't. It exposes every user's email, phone number and password hash to anyone who can call your API. Read the current user from auth.jwt(), keep public profile data in a profiles table, and read anything else through a private SECURITY DEFINER function.

Why does grant usage on schema auth to my_role succeed but change nothing? On hosted Supabase, postgres has USAGE on auth without the grant option, so its grant doesn't take effect. Wrap auth.uid() in a postgres-owned definer function and grant your role EXECUTE on that instead.

Can I still add a trigger on auth.users? Yes. Triggers and RLS policies on auth.users are still allowed, and so are foreign keys to it. Put the trigger function in your own schema, as SECURITY DEFINER with set search_path = ''.

Is this the same as "permission denied for table users"? Same SQLSTATE (42501), different gate. The schema error is about USAGE or CREATE on auth. The table error means the role can reach the schema but has no privilege on auth.users, which is how Supabase sets up anon and authenticated.

Did the October 30, 2026 Data API change cause this? No. That change affects new tables in public (details here). The auth schema has been closed to API roles from the start, and to your migrations since April 2025.

See which of your Supabase tables anyone can read, free

Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading