TLDR: If you use supabase-js's
.ilike()for lookups,%and_in the input are live PostgreSQL wildcards — and PostgREST exposes noESCAPEclause to defuse them. Strip before you pass, not after.
The Setup
I was running a late-night security audit on a law firm client's practice management system — a tool I built for a law firm client to handle client matters, intake forms, and document routing.
One of the intake paths is a Fillout form (an online intake-form platform) that fires a webhook with the submitter's email, then a findContactByEmail() function looks up the matching contact in Supabase and links the new matter to them.
Clean idea. Bad implementation.
The Wall I Hit First
My first thought when I spotted the raw .ilike("email", trimmed) call was: use ESCAPE 'x' — the standard SQL clause that lets you tell the DB "treat this character as literal, not a wildcard."
Totally standard. Totally unavailable.
PostgREST, the REST layer Supabase sits on, doesn't expose the ESCAPE clause. Neither does supabase-js's .ilike() method. It just sends the value straight through.
Okay, what about .eq()? That's parameterized and safe. But .eq() is case-sensitive — and email lookup is one of those places where you genuinely cannot afford to miss JANE@EXAMPLE.COM vs jane@example.com. So that door was closed too.
The Actual Exploit
Before I even had a fix, I wanted to understand how bad this was.
A POST to the Fillout webhook with email: "%@%" matched every contact in the DB. PostgreSQL sees %@% as "anything, then an at-sign, then anything" — which is literally every email address ever stored. .limit(1) took the oldest one. processIntake linked a brand new legal matter to a stranger's contact record.
The Fillout form is public-facing — anyone with the URL could do this. GREAT.
The Fix That Actually Worked
Strip the wildcards before they reach the query:
const exact = trimmed.replace(/[%_\\]/g, "");
if (!exact) return undefined;
// now .ilike("email", exact) is a safe case-insensitive exact match
Strip %, _, and \\ (defense-in-depth — \\ isn't the default escape character but can be with custom PostgreSQL config).
There's a real trade-off here: stripping _ means user_name@example.com loses its underscore and won't match the stored row that still has it. I accepted that duplicate-row risk in exchange for closing the wildcard class entirely. For most lookups, it's the right call.
Why the Bug Survived So Long
Here's the part that stings a little.
The paginated list functions — contact search, matter search — already ran through a sanitizeSearchTerm helper that strips ,()."%_: for both .ilike and .or callers.
The sanitizer existed. It just didn't cover findContactByEmail because that function didn't look like a search. It was a single-column exact lookup. Nobody thought to treat it like a filter expression.
That's the real lesson. Your sanitizer's blind spot is every path where untrusted input doesn't look like search. Audit by data-flow — follow the value from the edge to the query — not by what the function is named.
Anywhere you call .ilike() on external input is in scope. Check your lookups, not just your search bars.
P.S. — The same class bites the
.or()filter too, but it's been written up before. The single-column.ilike()case is the sneaky one because it really doesn't look dangerous until someone sends you%@%.