What it is#
SQL injection occurs when an application builds a SQL statement by joining strings, and part of that string comes from an attacker. Characters like ', -- and ; change the structure of the query, so the database executes logic the developer never wrote.
Common variants:
- In-band (error-based or UNION-based): results or database errors come back in the response.
- Blind (boolean or time-based): nothing is shown directly, but the attacker infers data from response differences or delays.
- Second-order: input is stored safely, then later concatenated into a different query, often in a background job or report.
Why it matters#
- Read any data the application's database account can reach: customer records, password hashes, API tokens.
- Bypass authentication by making a login query always return true.
- Modify or delete data, including audit logs.
- On some platforms and configurations, read files, call stored procedures or run operating system commands, turning a web bug into server compromise.
High to critical. Unauthenticated SQL injection on an internet-facing application is commonly CVSS 3.1 9.8.
Examples#
# Vulnerable: string formatting builds the query
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
// Vulnerable: concatenation in JDBC
String q = "SELECT * FROM orders WHERE id = " + request.getParameter("id");
ResultSet rs = stmt.executeQuery(q);
A tester confirming the issue typically sends a single quote and looks for a database error, or compares responses to id=5 AND 1=1 versus id=5 AND 1=2.
How to fix it#
1. Use parameterized queries everywhere. The query structure is sent separately from the values, so input can never become code.
# Fixed: parameter binding (psycopg / DB-API)
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
// Fixed: PreparedStatement
PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE id = ?");
ps.setLong(1, Long.parseLong(request.getParameter("id")));
// Fixed: node-postgres
await pool.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
2. Use ORM features safely. ORMs parameterize by default, but raw-query helpers (raw(), $queryRawUnsafe, extra(), string-built where clauses) bring the risk back.
3. Allow-list identifiers. Table names, column names and sort directions can't be bound as parameters. Map user choices to fixed values:
SORTS = {"name": "name", "created": "created_at"}
order_by = SORTS.get(request.args.get("sort"), "created_at")
4. Least privilege. Give the application's database account only the permissions it needs; never use a superuser or sa account. Disable features like xp_cmdshell.
5. Don't leak errors. Return generic errors to users and log details server-side.
How to test for it#
- Inventory every input that reaches a query, including headers, cookies, JSON bodies, sort/filter parameters and stored values reused later.
- Probe with syntax-breaking characters and boolean or time-based conditions; compare responses.
- Use tools such as sqlmap carefully and only within an authorized scope, with non-destructive options.
- Review code for string-built queries and unsafe ORM calls; add a static analysis rule to block them.
- After fixing, retest each parameter and add regression tests.