Blast radius limited by the kit’s RLS and Zod defaults, but string filters and definer functions are still yours to get right.
Last reviewed June 13, 2026 by SecureStartKit Team
The short answer
Supabase's PostgREST client parameterizes values, so column filters such as eq() and ilike() are safe from SQL injection by default. Two paths are still injectable: string-built .or() or .filter() expressions that interpolate user input, and Postgres functions that concatenate input into dynamic SQL. The fix is to pass values through column methods or parameterized EXECUTE, validate with Zod, and rely on Row Level Security as the real boundary.
Where it shows up: User input is interpolated into a .or() or .filter() string, or concatenated into a Postgres function’s dynamic SQL, instead of being passed as a bound value.
// app/actions/search.ts (Server Action)
'use server'
export async function searchDocuments(query: string) {
const supabase = createAdminClient()
// user input spliced straight into a PostgREST filter expression
const { data } = await supabase
.from('documents')
.select('*')
.or(`title.ilike.%${query}%,body.ilike.%${query}%`)
.eq('archived', false)
return data
}The string passed to .or() is filter grammar, not a bound value. Interpolating query lets an attacker inject extra OR conditions (x%,archived.eq.true) and surface rows the app-level filters were meant to hide.
// app/actions/search.ts (Server Action)
'use server'
import { z } from 'zod'
const Query = z.string().min(1).max(100)
export async function searchDocuments(input: string) {
const query = Query.parse(input)
const supabase = createAdminClient()
// value is bound by the column method, never spliced into the grammar
const { data } = await supabase
.from('documents')
.select('*')
.ilike('title', `%${query}%`)
.eq('archived', false)
return data
}.ilike(column, value) binds the value, so it can never become a new condition. Zod caps shape and length before the query runs, and Row Level Security still scopes every row to the caller.
-- concatenates input into dynamic SQL, runs as owner, bypasses RLS
create function search_users(_name text)
returns setof users
language plpgsql
security definer
as $$
begin
return query execute
'select * from users where name = ''' || _name || '''';
end;
$$;String concatenation into execute is classic SQL injection. security definer makes it cross-tenant: the function runs as its owner and skips every RLS policy.
-- parameterized, RLS-respecting, pinned search_path
create function search_users(_name text)
returns setof users
language plpgsql
security invoker
set search_path = ''
as $$
begin
return query execute
'select * from public.users where name = $1'
using _name;
end;
$$;The using clause binds _name as a parameter, so it cannot alter the statement. security invoker keeps RLS in force, and an empty search_path blocks search-path hijacking.
Take a notes app whose search runs .or() over the title and body, then narrows results with a separate .eq('archived', false). The .or() argument is a PostgREST filter expression evaluated with OR semantics, so any comma the attacker controls adds a new condition.
Sending the search term x%,archived.eq.true rewrites the query to "title or body matches x, OR archived is true". The injected clause is OR-ed in, and every archived note the app meant to hide comes back. No quotes, no classic ' OR 1=1, just the filter grammar turned against you.
The Postgres-function case is worse. A SECURITY DEFINER search function that builds its query with string concatenation runs as its owner and bypasses Row Level Security. A name argument of ' UNION SELECT email, encrypted_password FROM auth.users -- reads every user's credentials across every tenant. That is the textbook injection RLS would normally contain, except SECURITY DEFINER removed the containment.
Three greps find most of this in a few minutes.
First, in your application code, search for the string filter builders .or( and .filter(. Any match whose argument is a template literal or concatenates request data is suspect. Column methods like .eq(), .ilike(), and .in() are safe; the raw string filters are where injection lives.
Second, in your SQL migrations, grep for execute inside plpgsql functions and look for || concatenation beside it. Parameterized execute ... using and format(..., %L, ...) are safe; '...' || _arg || '...' is not.
Third, list every definer function with select proname from pg_proc where prosecdef and audit each one. Those bypass RLS, so an injection there is cross-tenant, not just cross-row. Pin search_path on each and downgrade to security invoker wherever the elevated rights are not actually needed.
Myth“I validate inputs with Zod, so I am safe from injection.”
Zod checks shape and length, not SQL safety. A 100-character string that passes Zod can still carry .or() filter operators or break out of a concatenated EXECUTE. Use Zod as one layer, not the fix.
Myth“Row Level Security will catch any injection.”
RLS limits which rows a query can touch, but it does not stop the injection, and SECURITY DEFINER functions skip RLS entirely. Injection inside a definer function is cross-tenant, not just cross-row.
Myth“It runs in a Server Action, so it is server-side and cannot be injected.”
Server-side execution does not make attacker-controlled input safe. The input still reaches the query; where the code runs is irrelevant to whether it injects.
Myth“I escaped the quotes in my dynamic SQL.”
Manual escaping is fragile and misses encodings and edge cases. Bind parameters with EXECUTE ... USING, or quote with format() and the %L specifier, instead of hand-rolled escaping.
SecureStartKit validates every Server Action input with Zod and enables Row Level Security on every table, so an injected column filter cannot read another tenant’s rows. Two things the defaults cannot do for you: stop you from hand-writing a string `.or()` filter with user input, and stop a `SECURITY DEFINER` Postgres function from bypassing RLS. Keep raw filter strings out of user-controlled paths, parameterize dynamic SQL, and reserve `SECURITY DEFINER` for the rare function that genuinely needs it.
How the kit wires Supabase queries