TLDR: If you use supabase-js's .ilike() for lookups, % and _ in the input are live PostgreSQL wildcards — and PostgREST exposes no ESCAPE clause 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 %@%.