Supabase RLS: Why auth.role() Is Not the Function You Want
auth.role() is the most misused function in Supabase row level security. Here is what it actually returns, why it will not give you your application roles, and the JWT claim and lookup-table patterns that work instead.

Supabase RLS: Why auth.role() Is Not the Function You Want
If you have written row level security policies in Supabase, you have probably reached for auth.role() and then wondered why it never returns 'admin', 'manager', or whatever roles your application defines. This is the single most common misunderstanding in Supabase RLS, and it produces policies that are either broken or — worse — silently permissive.
What auth.role() actually returns
auth.role() returns the Postgres role attached to the current request, not an application role. In practice that means one of three values:
anon— the request used the anonymous/publishable key with no signed-in userauthenticated— the request carries a valid user JWTservice_role— the request used the service key and bypasses RLS entirely
That is the whole set. It answers "is this request signed in?" and nothing else. It has no idea that your app has admins and staff and read-only auditors.
-- This policy does exactly nothing useful.
-- auth.role() will never equal 'admin'.
create policy "admins can delete"
on public.invoices for delete
using ( auth.role() = 'admin' ); -- always false
A policy whose using clause is always false is at least safe. The dangerous version is the inverse:
-- Grants every signed-in user in every tenant full read access.
create policy "staff can read"
on public.invoices for select
using ( auth.role() = 'authenticated' );
That policy is true for every authenticated user on the platform. If your app is multi-tenant, you have just handed every customer everyone else's invoices. This is the bug we find most often when auditing Supabase projects.
Related:
auth.role()is deprecated in newer Supabase versions in favour of reading the role from the JWT directly. Either way, the point stands — it is not an application role.
What you actually want
There are two patterns worth knowing, and the right choice depends on how often roles change.
Pattern 1 — Roles as a custom JWT claim (fast)
Put the role in the token, read it in the policy. No table join, so it costs nothing per row.
Set the claim with an auth hook:
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role text;
user_tenant uuid;
begin
select role, tenant_id into user_role, user_tenant
from public.profiles
where id = (event->>'user_id')::uuid;
claims := event->'claims';
claims := jsonb_set(claims, '{app_role}', to_jsonb(coalesce(user_role, 'member')));
claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_tenant));
return jsonb_set(event, '{claims}', claims);
end;
$$;
Then read it in a policy with a small helper:
create or replace function public.app_role()
returns text
language sql
stable
as $$
select coalesce(
nullif(current_setting('request.jwt.claims', true)::jsonb ->> 'app_role', ''),
'member'
);
$$;
create policy "admins can delete invoices"
on public.invoices for delete
using ( public.app_role() = 'admin' );
The trade-off: the claim is baked into the token at sign-in. Revoking someone's admin rights does not take effect until their token refreshes — up to an hour on default settings. For destructive permissions, that window is usually unacceptable.
Pattern 2 — Roles in a lookup table (correct, and fast enough)
Read the role from a table on every check. Changes take effect immediately.
create or replace function public.has_role(required text)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1 from public.profiles
where id = (select auth.uid())
and role = required
);
$$;
create policy "admins can delete invoices"
on public.invoices for delete
using ( public.has_role('admin') );
Two details matter for performance and correctness:
security definerplus a pinnedsearch_path. Without the definer, the function is subject to RLS onprofilesand you get infinite recursion. Without the pinned search path, you have a privilege escalation hole.- Wrap
auth.uid()in a subquery —(select auth.uid())rather than a bare call. Postgres then evaluates it once as an initplan rather than per row. On a table of any size this is the difference between a fast query and a sequential scan. It is the highest-leverage RLS optimisation in Supabase, and almost nobody applies it.
Tenant isolation is a separate policy from role
Roles answer "what may this user do". Tenancy answers "which rows exist for them". Conflating them is how data leaks between customers.
alter table public.invoices enable row level security;
alter table public.invoices force row level security;
-- Isolation: you only ever see your own tenant's rows.
create policy "tenant isolation"
on public.invoices for select
using ( tenant_id = public.current_tenant() );
-- Capability: only admins may delete, and still only within their tenant.
create policy "admins delete within tenant"
on public.invoices for delete
using ( tenant_id = public.current_tenant() and public.has_role('admin') );
Note force row level security. Without it, the table owner bypasses RLS — which includes the role your migrations run as, and is a nasty surprise when a scheduled function turns out to see everything.
Also index the column you filter on. create index on public.invoices (tenant_id); is not optional when every single policy references it.
The checklist
Before you ship RLS to production:
- RLS is enabled and forced on every table in
public. - No policy uses
auth.role()to mean an application role. - No policy uses
auth.role() = 'authenticated'as its only condition on multi-tenant data. auth.uid()is wrapped in(select ...)in every policy.- Every
security definerfunction pinssearch_path. - The service key exists only on your server. It bypasses RLS completely, so anywhere it leaks, every policy above is irrelevant.
- You have tested policies as a second tenant, not just as yourself.
That last one catches more bugs than the other six combined. Sign in as a user from tenant B and try to read tenant A's data by ID. If it works, you have found the bug before your customer did.
We build multi-tenant Supabase products for a living — including our own SaaS, ServicePilot, which runs row-level tenant isolation across web, iOS and Android. If you want a second set of eyes on your policies before launch, get in touch. Our Flutter + Supabase auth audit covers the client-side half of the same problem.

