SQL injection

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: The classic. User input is concatenated into a SQL query and ends up executing as code instead of being treated as data. Parameterized queries kill it dead. Every modern ORM does this for you.


In plain English

SQL injection happens when user-supplied text is concatenated into a SQL query, and the user supplies text that’s actually SQL code rather than data. The database can’t tell the difference — it executes whatever you send it.

The canonical example. Imagine a login query built like this:

SELECT * FROM users WHERE email = '" + userEmail + "' AND password = '" + userPassword + "'

If the user types a normal email like george@example.com, the query is fine:

SELECT * FROM users WHERE email = 'george@example.com' AND password = '...'

But what if they type ' OR '1'='1 as the password?

SELECT * FROM users WHERE email = '...' AND password = '' OR '1'='1'

The OR '1'='1' is always true. The query returns every user. The attacker is logged in as the first one — probably an admin.

That’s the simplest SQL injection. Real attacks can be much worse:

  • Dump entire tables (UNION SELECT * FROM users)
  • Drop tables ('; DROP TABLE users; --)
  • Modify data ('; UPDATE users SET role='admin' WHERE id=42; --)
  • Read system files (with some DBs)
  • Execute OS commands (in some configurations)

The fix is trivially easy: parameterized queries. Modern ORMs (Prisma, Drizzle), Supabase’s SDK, and basically every modern database library do this for you. You should never be writing string-concatenated SQL with user input in 2026. If a tutorial shows you doing that, it’s wrong.

SQL injection is part of OWASP Top 10 under A03 (Injection).


Why it matters

  • Total database compromise. A successful SQL injection often means the attacker can read, modify, or destroy ALL data — not just one user’s.
  • Auth bypass. The “always true” login pattern above is real and still found in old codebases.
  • Easy to find, easy to exploit. Automated scanners hammer every form field with SQL payloads.
  • Massive historical breaches. TalkTalk, Heartland Payment Systems, Sony Pictures — all included SQL injection in their breach stories.
  • The fix is simple AND the right pattern anyway. Parameterized queries are easier to read, safer, and faster (the DB can cache the plan).

How parameterized queries fix it

A parameterized query (also called prepared statement) separates the SQL structure from the data:

// Vulnerable (DO NOT DO THIS)
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// Safe — parameterized
const query = "SELECT * FROM users WHERE email = $1";
const result = await db.query(query, [email]);

The $1 is a placeholder. The database treats whatever value comes in via the parameter as a value, never as SQL code. If the user supplies ' OR '1'='1, the query becomes:

SELECT * FROM users WHERE email = '\' OR \'1\'=\'1'

The database tries to find a user with the literal email ' OR '1'='1 — finds none — returns empty. Attack defeated.

The separation between code (the query template) and data (the parameter) is what makes the attack impossible. The database has different code paths for parsing SQL vs. handling parameters; user input never reaches the SQL parser.


How modern tools do this for you

Supabase JS SDK

const { data } = await supabase
  .from("users")
  .select("*")
  .eq("email", userEmail);

The .eq("email", userEmail) is parameterized under the hood. No string concatenation; SQL injection impossible at this layer.

Prisma ORM

const user = await prisma.user.findUnique({
  where: { email: userEmail }
});

Same idea. The SQL is built by Prisma using parameters.

Drizzle ORM

const user = await db.select().from(users).where(eq(users.email, userEmail));

Parameterized.

Raw SQL with parameters (still safe)

If you write raw SQL using a library like pg (Postgres):

import { Pool } from "pg";
const pool = new Pool();
const result = await pool.query("SELECT * FROM users WHERE email = $1", [email]);

Parameterized via $1 placeholders. Safe.

Supabase’s .rpc() for stored functions

const { data } = await supabase.rpc("get_user_by_email", { email: userEmail });

Parameterized at the function boundary.


A concrete example: the bad and the good

Bad: string concatenation

async function searchPosts(query: string) {
  return await db.query(`SELECT * FROM posts WHERE title LIKE '%${query}%'`);
}
 
// Attacker calls: searchPosts("'; DROP TABLE posts; --")
// Resulting SQL: SELECT * FROM posts WHERE title LIKE '%'; DROP TABLE posts; --%'
// Posts table gone.

Good: parameterized

async function searchPosts(query: string) {
  return await db.query("SELECT * FROM posts WHERE title LIKE $1", [`%${query}%`]);
}
 
// Attacker calls: searchPosts("'; DROP TABLE posts; --")
// Resulting query: SELECT * FROM posts WHERE title LIKE $1
// With parameter: "%'; DROP TABLE posts; --%"
// Database searches for literal text containing "DROP TABLE posts" — finds nothing.

Same code length. Vastly different safety.

With ORM:

const posts = await prisma.post.findMany({
  where: { title: { contains: query } }
});
 
// Always safe, regardless of query content.

Variants and edge cases

Second-order SQL injection

User input is safely stored in the DB (parameterized), then later read back and concatenated into a query unsafely. The unsafe step matters.

NoSQL injection

Same idea, different syntax. MongoDB queries with $where operator can execute JavaScript. Don’t pass user input directly to query objects without validation.

// Vulnerable
db.users.find({ $where: `this.email == '${userEmail}'` });
 
// Safe
db.users.find({ email: userEmail });

LDAP injection

LDAP queries with special characters can be manipulated. Parameterize.

XPath injection

Same pattern for XML queries.

ORM injection (yes, really)

Some ORM features (raw queries, dynamic field names) can be vulnerable if not used carefully:

// Vulnerable — userColumn is user-controlled
prisma.$queryRawUnsafe(`SELECT ${userColumn} FROM users`);
 
// Safe — column is hardcoded
prisma.$queryRaw`SELECT email FROM users WHERE id = ${userId}`;

The tagged template literal in Prisma’s $queryRaw parameterizes; the Unsafe variant doesn’t.


What Supabase + RLS does

Supabase uses parameterized queries everywhere by default. Plus Row-Level Security (RLS) provides defense in depth — even if a SQL injection somehow got through, RLS still restricts what the (often anon role) connection can read.

The combination is genuinely strong. RLS doesn’t prevent SQL injection; it limits the damage if it happens.


Testing for SQL injection

  • Try classic payloads in inputs:
    • ' (a single quote) — common error indicator
    • ' OR '1'='1
    • '; DROP TABLE x; --
    • ' UNION SELECT NULL, NULL --
  • Automated: sqlmap is the standard tool.
  • Tools: Burp Suite, ZAP, snyk, Semgrep can flag SQL injection patterns in code.
  • ESLint rulessecurity/detect-non-literal-fs-filename and similar.

If any input produces a database error visible to the user — you have a problem (information leak at least; possibly injection).


Common gotchas

  • Constructing dynamic column or table names. Parameterization handles values, not identifiers. Whitelist column names instead.

  • Stored procedures that build dynamic SQL. Just because it’s in a stored procedure doesn’t make it safe. Same rules apply.

  • LIKE patterns. % and _ are SQL pattern characters. If user input is interpreted as a pattern, attacker may craft expensive queries (ReDoS-like, called LIKE-bomb). Escape user-supplied wildcards.

  • Type coercion confusion. Some DBs/drivers coerce types in surprising ways. Numeric IDs accidentally being string-concatenated can still hit.

  • ORM “raw” or “unsafe” features. Every ORM has an escape hatch. Search for $queryRawUnsafe, raw, query with template literals — review carefully.

  • Trusting client-side validation. “I check email format on the frontend” — attacker bypasses your frontend. Validate server-side.

  • Excessive privileges. Your app’s DB user probably doesn’t need DROP TABLE permissions. Least privilege limits damage.

  • Verbose error messages. Database errors echoed to users leak schema info (“Unknown column ‘admin’ in WHERE clause” tells the attacker about your schema). Catch and return generic messages.

  • Logging queries with user data. SQL injection logs may contain attempted payloads — fine for forensics, but also the attempted password etc. Filter or rotate.

  • Caching parameterized queries doesn’t make them unsafe. Prepared statements are designed to be reused; that’s good.

  • NoSQL “is safe by default” myth. NoSQL has its own injection patterns. Don’t assume.

  • GraphQL injection. Not exactly SQL injection, but resolvers that take user input and pass it to SQL unsafely have the same problem.

  • Migration scripts as code. Migration scripts running with admin DB credentials should NOT take user input. Run them in controlled environments.

  • Letting users construct dynamic queries. “Power user mode” with SQL-like filter syntax is a SQL-injection-shaped hole. Validate or use a safer DSL.

  • Re-using DB connections across users. If your connection has settings (search_path, role) leaking between users, attacker may manipulate. Reset state.

  • OR / IN clauses with user-supplied lists. WHERE id IN ('${ids.join(",")}') is vulnerable. Use parameterized arrays where supported.

  • Trusting frameworks blindly. Frameworks default to safe — but you can opt out. Code review escape-hatch usages.

  • Old code paths. Modern parts of your app may be safe; legacy endpoints may not be. Audit comprehensively.


See also

Sources