CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Overview
SQL Injection occurs when untrusted data becomes part of the text of a SQL query, so an attacker can change the query's logic, read data they should not see, or run administrative operations.
Relationship to Other CWEs
CWE-89 is a child of CWE-943 (Improper Neutralization of Special Elements in Data Query Logic) - use CWE-943 instead if the finding is against a NoSQL, document, key-value, or other non-relational query engine rather than a traditional relational SQL database.
OWASP Classification
A05:2025 - Injection
Risk
Critical: An attacker who controls the query's logic can read or modify anything the application's database account can reach, and can bypass authentication by making the lookup return a row whatever credentials are supplied.
Remediation Steps
Core Principle: Never build SQL by concatenating untrusted input; use parameterized queries (prepared statements) so user input is always treated as data, not query structure.
Trace the Data Path
Follow the untrusted value from where it enters to the SQL query it ends up in:
- Source: Where untrusted data enters (user input, external file, database, network request)
- Sink: The SQL execution function (
.execute(),.query(),.createQuery()) - String concatenation: Look for
+,concat(),format(), or template literals building SQL
Use Parameterized Queries (Primary Defense)
A parameterized query sends the query text and the values separately, so the database never parses untrusted data as SQL.
- Rewrite every query that concatenates input to use a prepared statement
- Put a placeholder (
?,$1,:name) in the query for each untrusted value and bind the value at execution - Never concatenate untrusted data into SQL strings, even alongside placeholders
- After the change, check that every untrusted value in the query reaches it through a placeholder
Use ORM Query Builders Safely
ORM query builders bind parameters for you, as long as you stay on the builder API:
- Use ORM methods like
.filter(),.where(),.find()with parameter binding - Avoid raw SQL; where it is unavoidable, pass values through the ORM's own parameter binding
- The language pages below show the safe form for each framework
Add Input Validation (Defense in Depth)
Even with parameterized queries, validate all untrusted data as an additional layer:
- Validate data types (numeric IDs should be integers)
- Validate format (dates, emails, UUIDs)
- Use allowlists for enumerated values
- Enforce length limits to prevent DoS
- Never rely solely on input validation as primary defense
Apply Least Privilege and Database Hardening
Restrict the application's database account so that an injection which does get through can do less:
- Give the account only the permissions the application uses; a read-only account for code that only runs SELECT
- Never grant DROP, CREATE, ALTER, TRUNCATE to application users
- Keep it away from system tables and system stored procedures such as
xp_cmdshell
Grant what the application needs; do not try to revoke what it never had.
The instinct is to write a list of REVOKE statements naming the dangerous
privileges, and it does not work in either of the two senses that matter. A
fresh user holds nothing to revoke - MySQL rejects the attempt outright with
ERROR 1147: There is no such grant defined for user unless you write
REVOKE IF EXISTS (8.0.16+) - and a revoke list can only name the privileges
its author thought of. Granting explicitly is the control; the default is
already "no privileges".
PostgreSQL:
CREATE USER app_user WITH PASSWORD :'app_password';
-- Grant only what the application needs, per table
GRANT SELECT, INSERT, UPDATE ON orders, customers TO app_user;
GRANT SELECT ON lookup_tables TO app_user;
-- DROP and ALTER are not grantable privileges in PostgreSQL - a table can only
-- be dropped by its owner or a superuser, so app_user simply must not own the
-- schema it reads. TRUNCATE and DELETE are grantable, and are withheld above
-- by not being granted.
MySQL/MariaDB:
CREATE USER 'app_user'@'app_server_ip' IDENTIFIED BY 'placeholder-set-at-deploy';
GRANT SELECT, INSERT, UPDATE ON myapp.users TO 'app_user'@'app_server_ip';
-- Nothing else is granted, so FILE, PROCESS, SUPER and SHUTDOWN are already
-- absent. Do not add REVOKE statements for them - see above.
SQL Server:
CREATE LOGIN app_user WITH PASSWORD = 'placeholder-set-at-deploy';
CREATE USER app_user FOR LOGIN app_user;
ALTER ROLE db_datareader ADD MEMBER app_user;
ALTER ROLE db_datawriter ADD MEMBER app_user;
-- db_datareader/db_datawriter convey no schema rights, so app_user cannot drop
-- a table. There is no "DROP ANY TABLE" permission to deny - dropping is
-- governed by ALTER on the schema or CONTROL on the object, neither of which is
-- granted here.
DENY ALTER ANY USER TO app_user;
DENY EXECUTE ON xp_cmdshell TO app_user;
What This Prevents:
With those grants in place, an injection that reaches the database still fails at:
'; DROP TABLE users; --- No DROP permission' UNION SELECT password FROM admin_users --- No access to admin tables'; EXEC xp_cmdshell 'rm -rf /' --- No EXECUTE on system procs
Key principle: Least privilege limits the damage; it does not stop the injection. The parameterized query is still the fix.
Test with Malicious Inputs
Verify your fixes by testing with SQL injection payloads:
' OR '1'='1(authentication bypass)'; DROP TABLE users--(data destruction)1' UNION SELECT password FROM admins--(data exfiltration)'; WAITFOR DELAY '00:00:10'--(blind SQLi detection)
Common Pitfalls
- Manual escaping instead of parameterization: Doubling quotes or running input through a database-specific escaping function looks like it neutralizes the injection character, but it only covers the escaping scheme the author had in mind - it does nothing for a numeric or unquoted context (
id = 1 OR 1=1), is easy to apply inconsistently across a large codebase, and some character encodings can still smuggle a way around it. Parameterized queries remove the problem instead of trying to out-escape it. - Blocklisting SQL keywords or characters at the input layer: Rejecting input containing
SELECT,DROP,--, or'blocks the textbook payload but not case variation, inline comments, or legitimate data that happens to contain those substrings. It also does nothing for second-order injection, where a value stored safely is later read back and concatenated into a different query without parameterization. - Parameterizing values while still concatenating identifiers: Binding the WHERE-clause values as parameters while building the table name, column name, or
ORDER BYdirection by string concatenation still leaves those positions injectable, because placeholders can only stand in for data values, not SQL structure. Identifiers need allowlist validation rather than parameter binding - or an ORM API that resolves the name against the mapped model and rejects anything that is not a real attribute. The language pages show that check where their framework offers one. - Validating input format without parameterizing the query: Checking that a value is numeric, the right length, or matches a regex reduces some attack patterns, but a value crafted to pass validation (
1followed by injected SQL in a field that allows more characters, or a numeric-looking string in a loosely typed language) can still reach a concatenated query. Validation is a defense-in-depth layer on top of parameterization, not a substitute for it.
Language-Specific Guidance
For framework-specific code examples, see:
- C# - ADO.NET, Entity Framework with parameterization
- Go - database/sql, GORM, sqlx with parameterized queries
- Java - JDBC, Spring Data JPA, Hibernate with parameterized queries
- JavaScript/Node.js - mysql2, pg, Sequelize, TypeORM, Knex.js
- Python - sqlite3, psycopg2, MySQL, Django ORM, SQLAlchemy
- PHP - PDO, MySQLi with prepared statements
Additional Resources
- CWE-89: SQL Injection
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Testing Guide - Testing for SQL Injection
- OWASP Top 10 2025 A05: Injection
- PostgreSQL string functions, including
quote_ident()andformat()- the database-side approach, shown here in PostgreSQL's spelling but present in most engines:format('... ORDER BY %I', col)quotes an identifier so it cannot break out of its position, whatever it contains. This is the one general alternative to an allowlist for the injection half of the problem. It is not an alternative for the other half - a quoted identifier is still any column the caller named, soORDER BY passwordruns, and ordering alone leaks a column a row at a time.