What SQL injection actually is
SQL injection happens when untrusted input is inserted into a SQL statement in a way that lets it change the statement's structure, not just its data. A login form that builds its query like this is the textbook example:
-- Application code builds this string directly from form input:
SELECT * FROM users
WHERE username = '' OR '1'='1' --' AND password = '...';
The input ' OR '1'='1' -- doesn't just fill in a value — it rewrites the WHERE clause into something that's always true and comments out the rest. That's the core problem: user input is being treated as part of the SQL language itself instead of as inert data.
Primary defense: parameterized queries
With a parameterized query, the SQL structure is fixed ahead of time and sent to the database separately from the values. The database knows, before it ever sees the input, exactly where a value belongs and treats it strictly as data — it cannot be reinterpreted as SQL syntax no matter what characters it contains.
-- The query shape is fixed; the value is bound as a parameter, not concatenated:
SELECT * FROM users
WHERE username = @username AND password_hash = @password_hash;
-- @username = "' OR '1'='1' --" is now just a literal string value to compare,
-- not a fragment of SQL — the query returns zero rows, as it should.
Every mainstream language and driver supports this: prepared statements in JDBC, ? or named placeholders in PDO/psycopg2, parameterized commands in ADO.NET, and equivalent APIs everywhere else. There is essentially no legitimate reason for new application code in 2026 to build a query by concatenating raw input into a SQL string.
Stored procedures aren't automatically safe
A stored procedure is only as safe as what happens inside it. If a procedure takes a parameter and then concatenates it into a dynamic SQL string before executing that string, it has simply moved the vulnerability one layer deeper:
CREATE PROCEDURE search_users (@name NVARCHAR(100))
AS
BEGIN
DECLARE @sql NVARCHAR(MAX);
SET @sql = 'SELECT * FROM users WHERE name = ''' + @name + '''';
EXEC(@sql); -- still vulnerable: @name is concatenated, not bound
END;
The fix, when dynamic SQL genuinely is required inside a procedure (dynamic table names, optional filter clauses), is to build the query text dynamically but still bind the actual values as parameters via sp_executesql or the equivalent, rather than folding them into the string:
CREATE PROCEDURE search_users (@name NVARCHAR(100))
AS
BEGIN
DECLARE @sql NVARCHAR(MAX) = N'SELECT * FROM users WHERE name = @name_param';
EXEC sp_executesql @sql, N'@name_param NVARCHAR(100)', @name_param = @name;
END;
Where ORMs quietly reopen the hole
Standard query-builder methods on most ORMs (Django's ORM, SQLAlchemy's query API, Entity Framework, ActiveRecord) parameterize automatically. The risk almost always comes from the raw-SQL escape hatch every ORM also provides, used for things a query builder doesn't handle cleanly — dynamic column sorting, complex full-text search, vendor-specific functions:
# Vulnerable: sort_column comes from a query string, dropped straight into raw SQL
query = f"SELECT * FROM products ORDER BY {sort_column}"
db.execute_raw(query)
# Safe: validate sort_column against an allow-list of real column names first
ALLOWED_SORT_COLUMNS = {"price", "name", "created_at"}
if sort_column not in ALLOWED_SORT_COLUMNS:
raise ValueError("Invalid sort column")
query = f"SELECT * FROM products ORDER BY {sort_column}" # now safe: value is from a fixed set
Column and table names can't be passed as bound parameters the way values can — parameter binding is for data, not for SQL identifiers — so dynamic sorting/filtering by column name has to be secured with allow-list validation against known-safe identifiers, exactly as shown above, not with parameterization.
Secondary defense: allow-list input validation
Validation doesn't replace parameterized queries, but it's a genuinely useful second layer: it catches malformed input before it reaches any query logic, and it's essential for the identifier cases above where parameter binding doesn't apply. The key distinction is allow-list versus blocklist:
OR, --, or quotes — is unreliable. Attackers have many ways to encode, case-vary, or reformat a payload to slip past a blocklist while it still executes once it reaches the database. Validate against what the input should look like (a known set of column names, a numeric ID pattern, an enum of valid statuses) rather than trying to enumerate everything it shouldn't contain.Escaping: last resort, not a strategy
Manually escaping quotes and special characters before concatenating input into a query is the oldest mitigation and the least reliable one — it's database-specific, easy to get subtly wrong (different encodings, multi-byte characters, second-order injection from data that was "safe" when first stored), and it's exactly what parameterized queries make unnecessary. It has a place only when a specific driver or legacy codebase genuinely offers no parameterization path — and even then, it should be treated as a stopgap while that path gets added.
Least privilege and defense in depth
Parameterization prevents the injection. Everything below limits the damage on the day some other defense fails anyway:
- Least-privilege database accounts — the application's DB user should only have the permissions it actually needs (SELECT/INSERT/UPDATE on specific tables), never rights to drop tables, alter schema, or read unrelated databases.
- Generic error messages — returning raw database error text to the client hands attackers a map of the schema; log the detail server-side and return a generic message to the user.
- A web application firewall — can catch known injection patterns in transit, but it's a net, not a fix; it should never be the only defense for a known-vulnerable query.
- Regular testing — static analysis (SAST) on the codebase and dynamic testing (tools like sqlmap in a controlled environment) against staging catches concatenated-query mistakes before they ship.
Common mistakes
- Assuming "we use an ORM" means "we're safe" — the raw-SQL escape hatch is where injection usually creeps back in.
- Parameterizing values but not validating identifiers — column/table names used in dynamic sorting or filtering need allow-list validation, since they can't be bound as parameters.
- Treating a stored procedure as inherently safe regardless of what it does internally with its parameters.
- Relying on a blocklist of "bad" keywords or characters instead of validating against what valid input should actually look like.
- Running the application under a database account with far more privilege than it needs, turning a contained injection into a full schema compromise.
Key takeaways
- Parameterized queries (prepared statements) are the primary defense — build the query structure once, bind input as data, never concatenate it into SQL text.
- Stored procedures and ORMs are only as safe as what happens inside them; both have escape hatches (dynamic SQL, raw query methods) that can reintroduce concatenation.
- Column and table names can't be parameterized like values — secure dynamic identifiers with allow-list validation instead.
- Escaping is a last resort for cases with no parameterization path, not a primary strategy.
- Least privilege, generic error messages, and testing don't prevent injection — they limit the damage when some other defense fails.