TLDR:
supabase-js's.ilike()passes your string directly as a PostgreSQLLIKEpattern. PostgREST exposes noESCAPEclause. 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.