SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/SQL Injection in Supabase Queries
CWE-89A05:2025 InjectionHigh severitySupabaseNext.js

SQL Injection in Supabase Queries

◐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.

The vulnerable patterns and their fixes

PostgREST filter injection (string .or / .filter)

✗Vulnerablets
// 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.

↓the fix
✓Securets
// 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.

Dynamic SQL in a Postgres function (RPC)

✗Vulnerablesql
-- 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.

↓the fix
✓Securesql
-- 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.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

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.

How to find it in your code

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.

Common mistakes

  • 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.

Does SecureStartKit prevent this?

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→

Frequently asked questions

Is Supabase safe from SQL injection by default?
Mostly. The PostgREST client parameterizes values, so column filters such as eq(), ilike(), and in() are safe. The gaps are string-built .or() and .filter() expressions and Postgres functions that build dynamic SQL with concatenation.
Does Row Level Security prevent SQL injection?
No. RLS limits which rows a query can return, which contains the damage, but it does not stop the injection itself. And SECURITY DEFINER functions bypass RLS, so injection there crosses tenant boundaries.
Can Zod alone stop SQL injection in Supabase?
No. Zod validates the shape and length of input, not its SQL safety. Pair Zod with bound column methods and parameterized EXECUTE, and treat it as defense-in-depth rather than the fix.
How do I write a safe search function in Postgres?
Use EXECUTE ... USING to bind parameters, or format() with the %L specifier, keep SECURITY INVOKER unless you truly need elevated rights, and pin search_path to an empty string to block search-path attacks.

References

  • CWE-89: SQL Injection (MITRE) ↗
  • OWASP A05:2025 Injection ↗
  • Supabase: Database Functions ↗
  • PostgreSQL: Executing Dynamic Commands ↗

Related weaknesses

  • Missing or Disabled RLS PolicyA table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.
  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.
  • IDOR: Missing Ownership CheckA Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.

Defined terms

  • Row Level Security
  • Backend-Only Data Access

Go deeper

  • Next.js CSRF, XSS & SQL Injection Prevention: The Complete Guide
  • RLS Policy Generator

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns