Advisor confirms: the only slip is the partially-scrubbed codename + the doubled article it left behind. Single sentence edit needed; all code blocks and table identifiers (wpm_email_allowlist, etc.) stay verbatim. Here is the cleaned post:


TLDR: If a Supabase helper creates its own createClient(), it has no session. RLS rejects the read. It fails silently. Everyone gets denied. Pass the caller's client instead.

The Setup

We were building an internal webinar project-management tool for an ecommerce business — Next.js 16, Supabase backend, Google OAuth, the whole stack.

First thing we wired up was an email allowlist gate (Row Level Security — Postgres per-row access rules). Only approved emails get in.

Migration 001, table wpm_email_allowlist, clean SELECT policy scoped to authenticated users.

Shipped it.

Locked out every single user immediately.

What I Tried First

I went straight for the service-role key — the "bypass RLS" nuclear option.

Made SUPABASE_SERVICE_ROLE_KEY optional, wired it in to the auth helper, read got through.

It worked. But it felt wrong. Now I had a second client lifecycle to manage, elevated a routine allowlist check above RLS for no good reason, and left a door open I didn't need open.

I backed the key out and looked harder.

The Actual Bug

Here's the thing. It worked fine in the Supabase SQL editor.

That's the tell, and I almost missed it.

The SQL editor runs as service-role. It bypasses RLS entirely. So the policy looked right — it was right — but the app code was doing something different.

The helper looked perfectly reasonable in isolation:

// src/lib/auth.ts
import { createClient } from "@supabase/supabase-js";

export async function isEmailAllowlisted(email: string): Promise<boolean> {
  const sb = createClient(url, anonKey, { auth: { persistSession: false } });
  const { data } = await sb
    .from("wpm_email_allowlist")
    .select("email")
    .ilike("email", email)
    .maybeSingle();
  return !!data;
}

Fresh client. No session. The session lives in request cookies, carried by @supabase/ssr's createServerClient with the cookie adapter.

This client has none of that.

RLS sees an unauthenticated read on a table protected by to authenticated. Denied.

And here's what makes this footgun so nasty: supabase-js does not throw. It returns { data: null, error: null }. My !!data check sees null, returns false, and the gate closes on everyone.

No exception. No stack trace. Just a quiet wrong answer.

The Fix That Actually Worked

Take the session-bound client as a parameter. Don't create one inside the helper.

import type { SupabaseClient } from "@supabase/supabase-js";

export async function isEmailAllowlisted(
  supabase: SupabaseClient,
  email: string
): Promise<boolean> {
  const { data, error } = await supabase
    .from("wpm_email_allowlist")
    .select("email")
    .ilike("email", email)
    .maybeSingle();
  if (error) console.error("isEmailAllowlisted error:", error.message);
  return !!data;
}

Callers pass their own authenticated client — the one built from cookies on the real request. The session is there. RLS passes. Everyone gets in who should.

The Audit Signal (Actually Useful)

If a helper imports createClient from @supabase/supabase-js (the base package — not @supabase/ssr) and it queries an RLS-protected table… it's almost certainly doing this wrong.

That import pattern is the tell. Go find it, refactor it.

Why This One Sticks With Me

The "works in SQL editor, breaks in app" mystery is deceptively confusing because your first instinct is to blame the policy.

The policy was fine the whole time.

It's the context that was wrong — and because Supabase fails silently on RLS denial, there's nothing obvious pointing you there.

Now I treat "null data, no error, feature not working" as suspect RLS context first. Every time.