← All posts
·6 min read·GuardLayer

Stop building SQL strings: injection in Next.js + Supabase apps

SupabaseSQL InjectionNext.jsRPC

If you take one habit away from this post, make it this: never assemble a SQL string out of a user value. Not with template interpolation (${...}), not with +, not "just for this one internal endpoint." The moment a value the user controls becomes part of the SQL text instead of a bound parameter, you have SQL injection — and Supabase doesn't save you from it.

A lot of teams assume Supabase is immune because they use the JavaScript client. It mostly is, as long as you stay on the query builder. The trouble starts the moment you reach for raw SQL — usually a Postgres function called through .rpc() — and build that SQL by hand.

What SQL injection actually is

SQL injection is when attacker-controlled input changes the structure of a query instead of just supplying a value. Consider this search helper:

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

// `username` comes straight from the request query string.
export async function searchUsers(username: string) {
  const query = `SELECT id, email FROM profiles WHERE username = '${username}'`;
  const { data, error } = await supabase.rpc("run_sql", { q: query });
  if (error) throw error;
  return data;
}
guardlayer scan · lib/searchUsers.tsLive engine output
Passed with warnings
92/100 · A
  • Warninglib/searchUsers.ts:10

    Possible SQL injection

    Use parameterised queries / prepared statements (placeholders + bound values) instead of building SQL strings. With Supabase prefer the query builder or .rpc() with typed args.

This looks harmless. It compiles, it works for username = "alice", and the demo passes. But username is interpolated into the SQL text, so the user gets to write SQL.

Send username = "' OR '1'='1" and the query becomes:

SELECT id, email FROM profiles WHERE username = '' OR '1'='1'

That returns every row in profiles. And because the input is part of the statement, an attacker can go further:

-- username = "' UNION SELECT email, encrypted_password FROM auth.users --"
SELECT id, email FROM profiles WHERE username = ''
UNION SELECT email, encrypted_password FROM auth.users --'

Read other tables, exfiltrate credentials, and — if the connection runs statements in series — drop data. This is the textbook CWE-89.

Note: run_sql is not a Supabase built-in. It's a custom Postgres function teams add to run arbitrary SQL through .rpc(). Its mere existence is the root of this bug — more on that below.

Why "but I have RLS" doesn't save you

Two reasons this is especially nasty in a Supabase app.

1. This code runs with the service_role key. Look at the sample again — it's using SUPABASE_SERVICE_ROLE_KEY, which bypasses Row Level Security by definition. The injected query runs with full database privileges. RLS isn't a weakened line of defense here; it isn't in the path at all.

2. RPC helpers often bypass RLS too. A run_sql-style helper — or any SECURITY DEFINER Postgres function — executes with the function owner's privileges. RLS policies are evaluated against the caller's role, so a definer function sidesteps them. The injected UNION against auth.users runs even though no RLS policy would ever let a client read that table directly.

RLS governs which rows a role may touch through the PostgREST API. It does nothing about a query whose structure the attacker rewrote.

The fix: let the database parameterize

The rule is simple — separate code from data. The SQL text stays fixed; user values travel as bound parameters that the database never parses as SQL. In a Supabase app you have two clean options.

Option A — use the query builder (preferred). For a straight lookup you almost never need raw SQL. The builder parameterizes everything for you:

export async function searchUsers(username: string) {
  const { data, error } = await supabase
    .from("profiles")
    .select("id, email")
    .eq("username", username); // bound value, not concatenated text
  if (error) throw error;
  return data;
}

.eq("username", username) sends username to PostgREST as a value, not as SQL text. There is no string for an attacker to break out of — ' OR '1'='1 is treated as a literal username that simply matches nothing. As a bonus, this call now runs through RLS, so it respects your policies.

Option B — when you genuinely need SQL, pass typed args to .rpc(). Define a Postgres function with named, typed parameters and let Postgres bind them:

create or replace function search_users(p_username text)
returns table (id uuid, email text)
language sql
stable
as $$
  select id, email
  from profiles
  where username = p_username   -- p_username is a bound parameter
$$;
export async function searchUsers(username: string) {
  const { data, error } = await supabase.rpc("search_users", {
    p_username: username, // typed argument, bound by Postgres
  });
  if (error) throw error;
  return data;
}

Here p_username is a real parameter. Inside the function body, where username = p_username compares against a bound value — there is no string concatenation, so there is nothing to inject.

If you must build dynamic SQL inside a function (for example, a dynamic ORDER BY), don't concatenate with ||. Use format() to quote identifiers safely and EXECUTE … USING to bind values:

-- inside a plpgsql function
execute format(
  'select id, email from profiles where %I = $1 order by %I',
  filter_col, sort_col            -- %I safely quotes identifiers
) using filter_val;               -- USING binds the value, not concatenated

A few more guardrails worth adopting:

  • Delete the run_sql escape hatch. A generic "run arbitrary SQL" RPC is an injection magnet and an RLS bypass rolled into one. If it exists, removing it is the highest-leverage fix on this list.
  • Don't reach for the service_role key in request handlers unless you truly need to bypass RLS. Use the anon key plus RLS for user-scoped reads.
  • Harden any SECURITY DEFINER function. Pin a fixed search_path (e.g. set search_path = public, pg_temp) and keep each definer function narrow and single-purpose.

30-second self-check

Grep your codebase for the two shapes that almost always mean trouble:

# 1. Template literal interpolating a value into a SQL statement
grep -rniE '`[^`]*(select|insert|update|delete)[^`]*\$\{' .

# 2. SQL keyword in a quoted string immediately followed by concatenation
grep -rniE '"(select|insert|update|delete)[^"]*" *\+' .

# 3. Any catch-all "run SQL" RPC
grep -rniE 'run_sql|exec_sql|execute *\(' .

If a value from req, searchParams, params, body, or formData ends up inside a string that also contains SELECT/INSERT/UPDATE/DELETE, rewrite it to use the query builder or a typed .rpc() call. Or scan the whole repo at once — GuardLayer flags string-built SQL (and 19 other Next.js + Supabase issues) on every push and shows the fix inline.

FAQ

Is the Supabase JS client safe from SQL injection? The query builder (.from().select().eq() etc.) parameterizes inputs and is safe. The risk is raw SQL you assemble yourself and run through an RPC or a direct Postgres connection.

Does RLS stop SQL injection? No. RLS governs which rows a role may touch through PostgREST. It doesn't prevent an attacker from rewriting a query's structure, the service_role key bypasses RLS entirely, and SECURITY DEFINER RPC functions run with the owner's privileges.

Is .rpc() dangerous? .rpc() itself is fine — what matters is how you pass values to it. Passing a hand-built SQL string as an argument is the bug. Passing typed, named parameters that the function uses directly is safe.

What about a dynamic ORDER BY or table name? Values can bind as parameters, but identifiers cannot. For dynamic identifiers, validate against an allowlist or quote them with format('%I', col) inside an EXECUTE. Never concatenate user input into an identifier.

Is escaping quotes enough? No. Manual escaping is brittle and misses encodings and edge cases. Parameterization is the only reliable fix — let the query builder or Postgres bind the value.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading