SQL Injection Testing: How to Find and Verify SQLi Vulnerabilities Automatically
SQL injection has been on the OWASP Top 10 for over a decade and remains one of the most commonly exploited vulnerability classes. It's also one of the most testable — automated tools can find many SQLi issues, and the patterns that make code vulnerable are identifiable through both static and dynamic analysis.
This guide covers the full testing workflow: manual detection techniques you can apply in code review, dynamic testing with SQLMap, verifying that parameterized queries actually protect you, and integrating SQLi testing into your CI pipeline so vulnerabilities don't slip through.
Understanding What Makes Code Vulnerable
Before testing, you need to recognize the patterns. SQL injection happens when user-controlled input is concatenated directly into a query string.
Vulnerable patterns:
# String concatenation — classic SQLi
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
# String formatting — same problem
query = "SELECT * FROM products WHERE id = %s" % product_id
cursor.execute(query)
# ORM raw queries with interpolation
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")Safe patterns:
# Parameterized query — the correct approach
cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
# ORM with parameters
User.objects.filter(username=username)
# SQLAlchemy with bound parameters
session.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)The key distinction: with parameterized queries, the database receives the query structure and the data separately. There is no string concatenation step where injection can occur.
Manual Detection: Reading Code for SQLi Patterns
Static analysis is your first line of defense. A focused code review for injection patterns takes minutes and catches issues before any dynamic testing is needed.
Search your codebase for dangerous patterns:
# Find string concatenation into SQL queries
grep -rn "execute.*%" src/
grep -rn "execute.*f\"" src/
grep -rn "execute.*format" src/
grep -rn "execute.*+" src/
# Find raw SQL in Django/SQLAlchemy
grep -rn "\.raw(" src/
grep -rn "text(f\"" src/
grep -rn "text(\".*{" src/This won't catch every case — complex string construction across multiple lines, dynamic query building from dictionaries, stored procedures that do their own concatenation — but it catches the common 80%.
For a more thorough static analysis, use a dedicated SAST tool:
# Semgrep with SQLi rules
pip install semgrep
semgrep --config=p/sql-injection src/
# Bandit for Python
pip install bandit
bandit -r src/ -t B608 # B608 is the SQL injection test
# FindSecBugs for Java (via Maven)
mvn findbugs:checkDynamic Testing with SQLMap
SQLMap is the standard tool for automated SQL injection testing. It tests parameters against dozens of injection techniques, identifies the database backend, and can demonstrate exploitability by dumping database content.
Basic usage:
# Test a GET parameter
sqlmap -u "http://testapp.example.com/products?id=1" --batch
# Test a POST body
sqlmap -u "http://testapp.example.com/api/search" \
--data='{"query": "test"}' \
--content-type='application/json' \
--batch
# With authentication (cookie)
sqlmap -u "http://testapp.example.com/api/orders?id=1" \
--cookie="session=abc123; csrftoken=xyz" \
--batch
# With Bearer token
sqlmap -u "http://testapp.example.com/api/orders?id=1" \
--headers="Authorization: Bearer eyJ0..." \
--batchThe --batch flag accepts default answers to all interactive prompts — essential for CI usage.
Useful SQLMap flags for developer testing:
# Level and risk — higher values test more aggressively
# Level 1 (default): basic tests
# Level 5: exhaustive tests including headers, cookies, referrer
# Risk 1 (default): safe tests only
# Risk 3: includes time-based tests that can be disruptive
sqlmap -u "http://testapp.example.com/api?id=1" \
--level=2 \
--risk=1 \
--batch
# Only test specific parameters
sqlmap -u "http://testapp.example.com/products?id=1&category=tools" \
-p id \
--batch
# Specify the DBMS to skip detection and speed up testing
sqlmap -u "http://testapp.example.com/api?id=1" \
--dbms=postgresql \
--batch
# Output format for CI reporting
sqlmap -u "http://testapp.example.com/api?id=1" \
--batch \
--output-dir=/tmp/sqlmap-resultsWhat SQLMap output means:
When SQLMap finds a vulnerability, it reports the injection point, technique, and payload:
Parameter: id (GET)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: id=1 AND 5908=5908
Type: time-based blind
Title: PostgreSQL > 8.1 AND time-based blind
Payload: id=1 AND 4599=SLEEP(5)
Type: UNION query
Title: Generic UNION query (NULL) - 1 to 20 columns
Payload: id=-4302 UNION ALL SELECT NULL,NULL,NULL,NULL,NULL-- YmCaMultiple technique findings on the same parameter are bad — it means the endpoint is thoroughly exploitable.
What SQLMap output does NOT mean:
No findings from SQLMap does not mean the parameter is safe. SQLMap tests a finite set of techniques against a finite set of injection points. It won't test every header, every JSON key nested three levels deep, or injection via XML if you didn't tell it to look there.
Testing Parameterized Queries Work Correctly
It's not enough to verify that your code uses parameterized queries — you should also verify the behavior at the database level. Some ORMs have escape hatches that bypass parameterization, and some database drivers have edge cases.
Write integration tests that verify injection payloads return empty results rather than unexpected data:
import pytest
from myapp import db, create_app
@pytest.fixture
def app():
app = create_app({"TESTING": True, "DATABASE_URL": "sqlite:///:memory:"})
with app.app_context():
db.create_all()
# Insert test data
db.session.execute(
db.text("INSERT INTO users (id, username, email) VALUES (1, 'alice', 'alice@example.com')")
)
db.session.commit()
yield app
def test_sql_injection_in_user_lookup_returns_empty(app):
"""Verify that SQL injection payloads don't return unexpected results."""
with app.app_context():
from myapp.models import User
injection_payloads = [
"' OR '1'='1",
"' OR 1=1--",
"admin'--",
"' UNION SELECT * FROM users--",
"1; DROP TABLE users--",
]
for payload in injection_payloads:
result = User.query.filter_by(username=payload).first()
assert result is None, (
f"Injection payload '{payload}' returned a result: {result}. "
"Parameterization may not be working correctly."
)
# Sanity check: legitimate lookup still works
alice = User.query.filter_by(username="alice").first()
assert alice is not None
assert alice.email == "alice@example.com"
def test_user_table_still_exists_after_injection_attempt(app):
"""Verify DROP TABLE payload doesn't affect database."""
with app.app_context():
from myapp.models import User
# Attempt DROP TABLE via injection
User.query.filter_by(username="'; DROP TABLE users; --").first()
# Verify table still exists and contains data
count = User.query.count()
assert count == 1, f"Expected 1 user, found {count}. DROP TABLE may have succeeded."Testing API Endpoints for SQLi
For REST APIs, test injection through HTTP requests. Combine pytest with the requests library for readable security tests:
import requests
import pytest
BASE_URL = "http://localhost:8080"
SQLI_PAYLOADS = [
"' OR '1'='1",
"' OR '1'='1'--",
"'; DROP TABLE users; --",
"' UNION SELECT null, username, password FROM users--",
"1 AND 1=1",
"1 AND 1=2",
"1' AND SLEEP(5)--",
"1; WAITFOR DELAY '0:0:5'--", # MSSQL time-based
]
@pytest.mark.parametrize("payload", SQLI_PAYLOADS)
def test_product_search_rejects_sqli(payload, auth_headers):
response = requests.get(
f"{BASE_URL}/api/products",
params={"search": payload},
headers=auth_headers
)
# Should not cause a server error
assert response.status_code != 500, (
f"Payload '{payload}' caused 500 error. Response: {response.text[:200]}"
)
# Response time should not spike (time-based injection detection)
assert response.elapsed.total_seconds() < 5, (
f"Payload '{payload}' caused {response.elapsed.total_seconds():.1f}s response time. "
"Possible time-based SQL injection."
)
# Error messages should not contain database details
if response.status_code >= 400:
body = response.text.lower()
assert "syntax error" not in body
assert "ora-" not in body # Oracle errors
assert "pg_" not in body # PostgreSQL internal tables
assert "mysql_" not in bodyDetecting False Positives
Automated SQLi scanners, including SQLMap, produce false positives. Before treating a finding as a confirmed vulnerability, verify manually:
Check 1: Does the response actually differ?
SQLMap's boolean-based detection compares two responses (with a true condition and a false condition). If your application randomly varies responses — different timestamps, session IDs, CSRF tokens in the body — SQLMap may see these variations as evidence of injection. Confirm by manually sending the two payloads and comparing responses.
Check 2: Is it time-based or response-based?
Time-based injection findings (SLEEP, WAITFOR DELAY) can be triggered by slow server response under load. Reproduce the finding in a quiet environment. If the delay disappears, it was likely a false positive caused by server load.
Check 3: Does it actually return data?
The ultimate confirmation for UNION-based injection is whether you can actually retrieve data from the database. If SQLMap reports a UNION injection, ask it to dump a table:
sqlmap -u "http://testapp.example.com/api?id=1" \
--technique=U \
--dump \
--tables \
--batchIf it can't retrieve data despite reporting the injection type, treat the finding with skepticism.
False positive reduction in SQLMap:
# Reduce false positives with stricter matching
sqlmap -u "http://testapp.example.com/api?id=1" \
--string="expected_string_in_valid_response" \
--not-string="string_only_in_error_response" \
--batchCI Integration
Running SQLMap in CI requires a running application. Use Docker Compose to spin up your application stack:
# docker-compose.test.yml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgresql://postgres:password@db/testdb
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=password
- POSTGRES_DB=testdb# .github/workflows/security.yml
name: SQL Injection Tests
on: [pull_request]
jobs:
sqli-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Start application stack
run: docker-compose -f docker-compose.test.yml up -d
- name: Wait for application
run: |
timeout 60 bash -c 'until curl -f http://localhost:8080/health; do sleep 2; done'
- name: Run parameterized injection tests
run: pytest tests/security/test_sqli.py -v
- name: Run SQLMap against test endpoints
run: |
docker run --network host \
securecodebox/sqlmap:latest \
-u "http://localhost:8080/api/products?search=test" \
--batch \
--level=2 \
--risk=1 \
--output-dir=/tmp/sqlmap \
--exit-code
# --exit-code makes SQLMap exit with code 1 if vulnerabilities found
- name: Upload SQLMap results
if: always()
uses: actions/upload-artifact@v3
with:
name: sqlmap-results
path: /tmp/sqlmap/For a lighter-weight CI approach, HelpMeTest can run your parameterized security tests on every pull request without requiring you to manage Docker infrastructure — pair it with SQLMap for full coverage.
Securing the Fix: Beyond Parameterized Queries
Parameterized queries solve the injection problem for standard queries. But some scenarios require additional care:
Dynamic table/column names: You can't parameterize table or column names. Use an allowlist:
ALLOWED_SORT_COLUMNS = {"name", "price", "created_at", "rating"}
def get_products(sort_by: str):
if sort_by not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"Invalid sort column: {sort_by}")
# Now safe to use in query — only allowlisted values pass
query = f"SELECT * FROM products ORDER BY {sort_by}"
return db.execute(query)Stored procedures: Ensure stored procedures themselves don't do string concatenation internally. Call them with parameters, but audit the procedure code too.
ORM raw queries: When you must use raw SQL in an ORM, always use bound parameters:
# Django — correct
User.objects.raw("SELECT * FROM users WHERE email = %s", [email])
# Django — wrong
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")
# SQLAlchemy — correct
db.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})SQL injection is well-understood and fully preventable. The combination of parameterized queries, static analysis in code review, dynamic testing with SQLMap, and automated regression tests gives you defense in depth — and the confidence to deploy knowing this class of vulnerability has been systematically addressed.