Skip to content

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 BY direction 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 (1 followed 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