← All posts
·6 min read·GuardLayer

supabase.auth.admin.getUserByEmail is not a function

SupabaseAuthAdmin APIEnumerationSecurity

supabase.auth.admin.getUserByEmail is not a function because no such method exists — the admin API looks users up by UUID only. The closest supported thing is an undocumented filter query parameter on GET /auth/v1/admin/users, which does a case-sensitive substring match, not an exact lookup. Whatever you build on top of it, keep it behind a server-side auth check.

You need a user's record and all you have is their email. getUserById wants a UUID. So you try the obvious name, and get:

TypeError: supabase.auth.admin.getUserByEmail is not a function

It isn't a version problem and it isn't a typo. Here is the entire user surface of auth.admin in @supabase/auth-js v2.106:

signOut            inviteUserByEmail   generateLink
createUser         listUsers           getUserById
updateUserById     deleteUser

One user getter, keyed by uid, with a validateUUID(uid) call as its first line. (There are also mfa, oauth, customProviders and passkey namespaces hanging off admin, none of which look a person up by email.) There is no by-email variant.

How do I get a Supabase user by email?

There is one supported path, and it's easy to miss because the JS client doesn't expose it: the GoTrue admin endpoint accepts a filter query parameter.

// The client builds its query from `page` and `per_page` only,
// so you have to call the REST endpoint directly.
const res = await fetch(
  `${process.env.SUPABASE_URL}/auth/v1/admin/users?filter=${encodeURIComponent(email.toLowerCase())}`,
  {
    headers: {
      apikey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
      Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY!}`,
    },
  },
);
const { users } = await res.json();

Two things about filter that will bite you if you treat it as a lookup:

It's a substring match, not equality. Server-side it becomes roughly email LIKE '%<filter>%' OR raw_user_meta_data->>'full_name' ILIKE '%<filter>%'. Passing alice@acme.com will also return alice@acme.com.br, and passing a bare name can match the display name of a completely different account.

Lowercase the filter before you send it. GoTrue normalises addresses to lowercase before storing them, and the email half of that predicate is LIKE, not ILIKE. Send Alice@acme.com exactly as the user typed it and you get zero results for a user who definitely exists.

So filter narrows the page; it does not identify a user. Always re-check for exact equality yourself:

const match = users.find(
  (u) => u.email?.toLowerCase() === email.toLowerCase(),
);

The four workarounds, ranked by blast radius

1. filter + exact re-check on the server (best). One request, no extra schema, no new attack surface. Runs with the service role key, so it must live in a route handler, server action or edge function that is already authenticated.

2. Paginate listUsers yourself. What most people land on. It works, and it's a full scan of your user table on every call to answer a yes/no question — listUsers({ page, perPage }) has no filter at all, so it degrades quietly as you grow rather than failing outright.

3. A SECURITY DEFINER function over auth.users. Tempting because it's one SQL statement and callable via rpc(). It also runs with the definer's privileges, bypasses RLS on everything it touches, and — if you grant execute it to anon — turns your entire user table into a public API. If you go this route at all, pin set search_path = '', check auth.uid() as the function's first statement, and revoke anon. The same hazards apply as with any SECURITY DEFINER function without a pinned search_path.

4. Mirroring emails into a public profiles table. A profiles row per user with the email on it, readable by anon "just for the signup form", is a complete address export for anyone holding your publishable key. This is the same failure as the signup "email already exists" check.

And one non-option: inviteUserByEmail as an existence probe. It has side effects — it sends mail — and its behaviour on an existing user isn't a contract you should build on.

Why the method's absence is a feature

The missing getter isn't an oversight. getUserById is a lookup on a value only your server can already hold; a by-email getter is a lookup on a value anyone can guess. Once "does this address have an account?" is a one-call answer, it tends to end up behind a public endpoint, because that's the shape the signup form wants.

That's the whole reason Supabase returns the same "Invalid login credentials" for a wrong password and a missing account, and why password reset succeeds silently for addresses that don't exist. Handing the same answer back through a convenience method would undo it.

Which makes the real question not "how do I check if a user exists" but "why does my flow need to know?" Usually the honest answer is a UX nicety — and the alternative is to make signup idempotent: let the user submit, let Auth decide, and send the "you already have an account, here's a login link" email from the server.

The version that leaks

Here's what the workaround looks like when the pagination approach gets deployed as-is:

import { createClient } from "jsr:@supabase/supabase-js@2";

// getUserByEmail doesn't exist, so page through every user instead.
// Called from the signup form to say "you already have an account".
const admin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

Deno.serve(async (req) => {
  const { email } = await req.json();

  const { data } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 });
  const exists = data.users.some((u) => u.email === email);

  return new Response(JSON.stringify({ exists }), {
    headers: { "Content-Type": "application/json" },
  });
});
guardlayer scan · supabase/functions/check-user/index.tsLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/functions/check-user/index.ts:1

    Edge function without auth validation

    Read the Authorization header and verify the JWT (supabase.auth.getUser(token)) before doing any work, or set verify_jwt = true in the function config.

Nothing here is a typo. The key is server-side, the logic is correct, and it still ships a public account-existence API — because Supabase edge functions are invocable by anyone who knows the URL unless you verify the Authorization header yourself or set verify_jwt = true. The function never reads that header, so curl gets the same answer your signup form does.

A 60-second self-check

# 1. Anything paging the whole user table?
grep -rn "auth.admin.listUsers" app/ supabase/functions/ lib/

# 2. Any SQL function reading auth.users?
grep -rniE "from auth\.users|security definer" supabase/migrations/

# 3. Any of those granted to anon?
grep -rniE "grant execute .* to .*anon" supabase/migrations/

Every hit needs an answer to one question: what stops an unauthenticated caller from reaching this? GuardLayer automates part of that — check 2 maps to its SECURITY DEFINER mutable-search_path rule, and it separately flags edge functions with no JWT verification and the service role key escaping the server. Checks 1 and 3 have no rule behind them today; those you answer by hand.

FAQ

Does getUserByEmail exist in any Supabase client version? Not in current auth-js. The admin API exposes getUserById as its only user getter, and it validates that the argument is a UUID before sending the request.

Is the filter parameter officially documented? It's implemented in the GoTrue admin endpoint and it works, but the JS client doesn't surface it and it isn't in the client reference. Treat it as a supported endpoint feature with an unsupported client story, and pin your assumptions with a test.

Can I just use listUsers with a large perPage? For a few hundred users, yes. It's a full scan of your user table on every call, so it gets slower as you grow without ever breaking loudly enough to notice.

Is a SECURITY DEFINER function over auth.users ever acceptable? Only with set search_path = '', an auth.uid() check as the first statement, execute revoked from anon, and a clear reason why option 1 doesn't work. Most of the time option 1 does work.

What should the signup form do instead? Submit it. Let Auth decide, and have the server send either a welcome email or a "you already have an account" email. The user gets a better experience and you never build the oracle.

Catch this before it ships — free

GuardLayer scans every push for this and 33 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.