TLDR: We had a Supabase project shared across five apps with one Google OAuth client and no domain restriction on signups. We gated every table with RLS but left the front door open — anyone with a Google account could create an auth account. One Before-User-Created hook fixed it. The thing that blocked us for weeks wasn't complexity. It was a stale assumption we never stopped to verify.
the setup
an ecommerce business runs a single Supabase project behind five different apps. KOL CRM, supply chain, webinar PM, affiliate payouts, org chart. One database. One Google OAuth client.
That's already a lot of surface area.
the wall we hit
A developer at our development partner flagged it first: the RLS policies on this shared project were essentially to authenticated using (true) — any authenticated Supabase user could read and write through PostgREST (Supabase's auto-generated REST API layer).
We fixed that. Wrote migration 024_rls_gate_all_open_policies.sql. Built public.is_allowed_user() — a security-definer function that checks the JWT email against an allowlist of approved domains. Applied it across ~144 policies, project-wide.
That locked the windows.
But the front door was still open.
Any Google account could still sign up and get a row in auth.users. If we ever missed a policy — even one — they'd have access.
why we didn't close it sooner
There was a standing reason to avoid domain-locking the OAuth: the webinar app onboards external collaborators. Not the client's own staff. People with random email addresses.
Same OAuth client. Same Supabase project. Lock the domain and you lock them out.
So we kept the front door open and added more locks inside.
The problem: that assumption was stale. We never actually checked whether any external collaborators had signed up.
When we finally ran the audit — select split_part(email,'@',2), count(*) from auth.users group by 1 — the result was brutal in the best way.
Zero external collaborator accounts. Not one. The justification that had kept us from doing the clean fix for weeks didn't exist anymore.
the fix that worked
Supabase has a Before User Created hook (Dashboard → Authentication → Hooks). You wire it to a Postgres function with this signature:
create or replace function public.hook_restrict_signup_by_domain(event jsonb)
returns jsonb language plpgsql as $$
declare
email text := event->'user'->>'email';
domain text := split_part(email, '@', 2);
allowed text[] := array['yourdomain.com', 'yourlabs.com'];
begin
if domain <> all(allowed) then
return jsonb_build_object(
'error', jsonb_build_object(
'message', 'Signups restricted to authorized domains.',
'http_code', 403
)
);
end if;
return '{}';
end;
$$;
Then — and this part matters — you must do the grant/revoke explicitly:
grant execute on function public.hook_restrict_signup_by_domain to supabase_auth_admin;
revoke execute on function public.hook_restrict_signup_by_domain from authenticated, anon, public;
New signups from outside the approved domains get a 403. Before they ever create an account.
the gotchas
Gotcha 1 — the hook only blocks NEW signups.
Existing out-of-domain auth.users rows are completely unaffected. You still have to audit and DELETE them separately. In our case, there was exactly one: a test account a developer at our partner firm had used to prove the open-OAuth hole was real. (We kept it. Intentionally. Future audits will re-flag it — that's fine.)
Gotcha 2 — on a shared project, your allowlist must include every legit domain.
The domain on the allowlist isn't the development partner's own email domain. It's the client's internal labs division domain — the one actual staff accounts use. Get this wrong and you lock out real people on day one.
why this matters
We had a technically correct defense — RLS policies gating every table — and we told ourselves it was enough because the "real" fix would break someone.
It wouldn't have. We just never checked.
The lesson I carry now: before you build a workaround because the clean fix "will break X," audit whether X actually exists. And when you do find the clean fix — lock at the signup layer, not just the data layer. One hook is a lot harder to miss than 144 policies.
Defense in depth means both layers, not one layer as a substitute for the other.
P.S. The hook runs before the user row is written — no rollback needed if it rejects. That's a GENIUS design. Block at the edge, not in the middle.