TLDR: supabase-js's .ilike() passes your string directly as a PostgreSQL LIKE pattern. PostgREST exposes no ESCAPE clause. Strip % and _ from untrusted input before it gets there — or your users can probe your whole dataset for free.

The Setup

I was doing a security sweep on a law firm client (my Supabase-backed practice management app — Supabase is a Postgres host with a REST API layer called PostgREST sitting in front of it).

Most of the codebase was already in good shape. We had a sanitizeSearchTerm helper that stripped ,()."%_: before any user input reached a query filter. The paginated list functions all called it.

I thought we were clean.

The Thing I Almost Missed

Then I audited findContactByEmail.

No sanitizer. Raw string, straight into .ilike('email', value).

This is the one that bites you. Most injection patterns look like injection — you see a filter expression, a comma, parentheses, some obvious separator characters. But .ilike('email', userInput) just looks like… a method call. Friendly. Boring. Safe.

It isn't.

Why .ilike() Is Different

PostgreSQL's LIKE and ILIKE treat % as "any sequence of characters" and _ as "any single character." Standard SQL supports an ESCAPE clause to disarm them. Something like:

WHERE email ILIKE '%input%' ESCAPE '\'

PostgREST doesn't expose that clause. Neither does supabase-js's .ilike() method. There's no option to pass, no flag to flip. What you give it becomes a live wildcard pattern, full stop.

So if a user searches for %, they match every row. If they search for %@company.com, they pull every contact at that domain whether your app was supposed to show them all or not.

What I Tried First

My first instinct was to reach for .eq() instead — it is parameterized and genuinely safe against this class of attack.

But .eq is case-sensitive. For email lookup you almost always want case-insensitive match, and without a lower(email) functional index already in place, .ilike() is the only tool supabase-js hands you. Swapping to .eq wasn't the answer.

I looked for the ESCAPE clause in the PostgREST docs. Not there. Not in supabase-js either.

So what? GREAT question. Sanitize the input yourself before it touches the query.

The Fix

Our existing sanitizeSearchTerm helper already stripped %, _, and the .or()-specific separators (,()."). One function call — that was the whole patch.

// Before
const { data } = await supabase
  .from('contacts')
  .select('*')
  .ilike('email', searchValue)

// After
const { data } = await supabase
  .from('contacts')
  .select('*')
  .ilike('email', sanitizeSearchTerm(searchValue))

findContactByEmail got the fix. The paginated list functions had always had it. We were clean.

Why This One Slips Through

The thing is, .or() filter injection is well-documented — there are examples everywhere of why you strip commas and parentheses from those. But the ilike-on-a-single-column case? It doesn't look like a filter expression. It looks like a string comparison. Your eyes slide right over it in code review.

The rule I'm running with now: any untrusted string that reaches .ilike() needs to go through a wildcard stripper first. No exceptions, no "it's just one column." The surface doesn't announce itself.

Parameterize where you can. Sanitize where you can't. Know which situation you're in.