← All posts
·9 min read·GuardLayer

Query Supabase auth.users table without leaking emails

SupabaseAuthRLSPostgresSecurity

You can't query auth.users from supabase-js, because Supabase keeps the auth schema out of the Data API, and the three usual workarounds (a public view, an anon-callable SECURITY DEFINER function, the service-role key in the browser) each hand every user's email to anyone who loads your site. Copy the columns you need into public.profiles, join against that, and call auth.admin.listUsers() only from server code behind an admin check.

Every app eventually needs user data next to app data: a member list, a "posted by" name, an admin screen. It lives in auth.users, which supabase.from() can't reach, and the quick fixes get there by publishing the table.

Can you query auth.users from supabase-js?

No. Supabase's user data guide says it in one line: "For security, the Auth schema is not exposed in the auto-generated API." auth.users holds each user's email, phone, password hash (encrypted_password), raw_app_meta_data, raw_user_meta_data and sign-in timestamps. The same guide gives the way forward: "If you want to access users data via the API, you can create your own user tables in the public schema."

Server code can still read it, through the admin API with the service-role key or over a direct Postgres connection. The question is which caller ends up reading it.

The three workarounds that dump the user table

1. A public view over auth.users

The shortest of the three, at two statements:

-- supabase-js can't reach auth.users, so expose it for the
-- member directory and the "posted by" names.
create view public.users as
  select id, email, raw_user_meta_data, created_at
  from auth.users;

grant select on public.users to anon, authenticated;
guardlayer scan · supabase/migrations/20261024090000_users_view.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20261024090000_users_view.sql:7

    Table or view granted to the anon role without RLS

    For a table, enable RLS (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies. For a view, create it WITH (security_invoker = true) and repeat that clause on every CREATE OR REPLACE VIEW. Materialized views and foreign tables cannot be protected: revoke the anon grant or move them to a schema the Data API does not expose. Prefer per-table grants over ON ALL TABLES IN SCHEMA public. Only keep an anon grant for genuinely public data that is still RLS-protected.

A view runs with its owner's privileges, so this one reads auth.users as postgres for every caller, the same way a security definer view bypasses RLS. Supabase's linter rates the result ERROR:

"a public security definer view referencing auth.users exposes all user records to all API users" — splinter lint 0002, Exposed Auth Users

GET /rest/v1/users?select=email with your publishable key returns the whole list. Whether the grant line is even needed depends on your side of Supabase's Data API grant change:

  • Not switched yet (older projects, until October 30, 2026): default privileges give anon and authenticated SELECT on every new table, and in Postgres that includes views. The view leaks with no grant line at all.
  • Switched (new projects since the May 30, 2026 rollout, and every project from October 30): the bare view fails with a permission error, and the grant above is what gets pasted to fix it.
  • Either way, existing views stay exposed. The Postgres docs on default privileges: "It does not affect privileges assigned to already-existing objects." A view created last year keeps leaking after October 30.

What the scanner sees: supabase/grant-to-anon fires because the view and its anon grant sit in one migration (a materialized view created and granted the same way gets the same warning). It does not fire on the same view with no grant in the file (the pre-switch case), on a grant to authenticated only (with open signups, every account reads every email), or on a view from an earlier migration. The Security Advisor runs lint 0002 against live privileges, so check it too.

security_invoker = on doesn't rescue this view: the caller then needs its own SELECT on auth.users, and granting that is the linter's option 2, covered below.

2. A SECURITY DEFINER function

create function public.list_users()
returns table (id uuid, email text)
language sql security definer set search_path = ''
as $$ select id, email::text from auth.users $$;

supabase.rpc('list_users') then returns every address, with no grant execute needed: Postgres gives EXECUTE on new functions to PUBLIC by default, so unless your project revoked that default, anon can call it as soon as the migration runs. If you were reaching for it to look up a user by email, that post ranks it, and the emails-in-profiles mirror, against the safer options.

GuardLayer flags this function only when its search_path is unpinned. Pinned as above, it reports nothing: no rule checks who can EXECUTE a function. The Security Advisor's "Public Can Execute SECURITY DEFINER Function" lint covers the live state.

3. The service-role key in the browser

auth.admin.listUsers() works from a client component if you hand it the service-role key:

"use client";
import { createClient } from "@supabase/supabase-js";

const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
);

export const loadUsers = () => admin.auth.admin.listUsers();

This ships the key that bypasses RLS on every table, not just the user list. The listUsers reference is blunt: "This function should only be called on a server. Never expose your service_role key in the browser." GuardLayer flags this file critical three times: supabase/service-role-key-in-client, supabase/service-role-in-public-env and nextjs/public-env-secret. If it ever shipped, rotate the key.

The safe pattern: extend the user table with profiles

This is lint 0002's option 1 and the pattern in Supabase's guide: a public.profiles table holding only what other users may see, keyed to auth.users(id), guarded by RLS.

create table public.profiles (
  id uuid primary key references auth.users (id) on delete cascade,
  display_name text,
  avatar_url text,
  created_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

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

create policy "signed-in users read profiles" on public.profiles
  for select to authenticated
  using (true);

create policy "users update own profile" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);
  • Only public-safe columns. No email, no phone. Every signed-in account can read this table, and with open signups that means anyone with an inbox. Lint 0002's own example uses using ( true ), which is fine only because its table holds names, not addresses.
  • The foreign key targets the primary key. Supabase's guide: "Only use primary keys as foreign key references for schemas and tables like auth.users which are managed by Supabase."
  • Revoke, then grant. Clearing Supabase's default table grants first makes the migration behave the same on both sides of October 30, and it's what makes the column-level UPDATE grant real: a column grant does nothing while the role still holds table-level UPDATE.
  • A trigger on auth.users fills it at signup. If the function isn't security definer, signups fail with "Database error saving new user"; that post has a safe version with a pinned search_path.

GuardLayer reports one info-level note on this file, for the using (true) read policy: every signed-in user reads every row, which is the intent here.

How do I join auth.users in a query?

You join profiles instead. PostgREST builds embeds from foreign keys between tables it can see, and it can't see auth.users. So point app foreign keys at profiles:

create table public.posts (
  id bigint generated always as identity primary key,
  author_id uuid not null references public.profiles (id),
  title text not null
);
alter table public.posts enable row level security;
-- plus grants and policies, as above
const { data } = await supabase
  .from("posts")
  .select("id, title, author:profiles(display_name, avatar_url)");

How do I list all users?

On the server, with auth.admin.listUsers(), behind your own admin check:

// app/api/admin/users/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server"; // cookie client, publishable key
import { adminClient } from "@/lib/supabase/admin"; // "server-only", service role

export async function GET() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (user?.app_metadata?.role !== "admin") {
    return NextResponse.json({ error: "forbidden" }, { status: 403 });
  }

  const { data, error } = await adminClient.auth.admin.listUsers({ page: 1, perPage: 50 });
  if (error) return NextResponse.json({ error: error.message }, { status: 500 });
  return NextResponse.json(data.users.map((u) => ({ id: u.id, email: u.email })));
}

app_metadata works for the role check because users can't edit it, unlike user_metadata. The limit: GuardLayer can't tell this route from one with the check deleted. A route handler or server action returning listUsers with no check gets no finding; only an Edge Function without caller verification does (supabase/edge-function-no-auth).

Should you use the linter's option 2?

Usually not. Option 2 in lint 0002 makes the view security_invoker=on, runs grant select on auth.users to authenticated; and alter table auth.users enable row level security;, and adds a select_self policy. Supabase's April 2025 auth-schema restrictions (discussion #34270) still let you "Create RLS policies and database triggers" on auth.users.

The catch is the grant. It's table-level, so it covers every column: password hash, tokens, phone, app metadata. RLS scopes rows, not columns, so the view's column list is the only column filter, and it protects that one view. A later invoker view or function over auth.users runs with the whole-table grant, and if someone widens select_self to show teammates, each of those can return teammates' full auth rows. With profiles, auth.users keeps its default privileges and you choose the exposed columns. GuardLayer has no rule for grants on auth.users, and the select_self policy is scoped to auth.uid(), so it reports nothing on option 2.

Quick self-check

-- Views over auth.users in public, and which API roles can read them
select distinct c.relname, c.relkind, c.reloptions,
       has_table_privilege('anon', c.oid, 'SELECT') as anon_reads,
       has_table_privilege('authenticated', c.oid, 'SELECT') as authenticated_reads
from pg_depend d
join pg_rewrite r on r.oid = d.objid
join pg_class c on c.oid = r.ev_class
join pg_namespace n on n.oid = c.relnamespace
where d.classid = 'pg_rewrite'::regclass
  and d.refobjid = 'auth.users'::regclass
  and n.nspname = 'public';
grep -rniE "from +auth\.users" supabase/migrations/
grep -rn "auth.admin.listUsers" app/ lib/ components/

A true on a materialized view (relkind m), or on a view whose reloptions don't turn security_invoker on, is a live leak: drop it or revoke it. Every listUsers hit needs a server-side admin check in front of it.

FAQ

Can I query auth.users with supabase-js? No. The auth schema isn't exposed in the auto-generated API. Copy what you need into public.profiles, or call the admin API from server code.

How do I get the current user's email? supabase.auth.getUser() returns the signed-in user, email included. You never need auth.users for your own row.

Is a view over auth.users ever safe? The linter accepts one API-readable kind: a security_invoker view with RLS on auth.users (option 2, with the caveats above). Otherwise, only if no API role can select it: revoke it from anon and authenticated, or keep it in a schema the Data API doesn't expose. Then it's a server-side convenience, not something supabase-js can use.

Does the October 30, 2026 change fix an existing view over auth.users? No. It changes default privileges for objects created afterwards. Existing views keep their grants until you revoke them or drop the view.

Does GuardLayer catch a view over auth.users? Only when the same migration creates the view and grants it to anon or PUBLIC. The Security Advisor's Exposed Auth Users lint reads live privileges, so it also catches the no-grant and authenticated-only cases a per-file scan misses.

Catch this before it ships — free

Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.

No signup, no card. Raw files aren’t kept; only the report is saved.

Keep reading

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