Preventing SQL Injection
By the end of this lesson
Use parameterised queries to remove the risk structurally.
Every database call sends text to the database engine, and the engine reads that text as a statement: keywords, identifiers, operators and literal values, all in one string.
SQL injection is what happens when a value supplied by a user ends up inside that string before the engine parses it. The engine has no way to tell which characters came from your source code and which arrived in a form field — it receives one string and parses all of it. If the value alters the structure of the statement, the engine runs the altered structure, because as far as it is concerned, that is the statement.
So the cause is not a lack of filtering. The cause is that data was placed where code goes. Fix that and the problem stops existing, rather than being repeatedly caught.
// FLAWED SHAPE. The value becomes part of the statement text before the
// database ever sees it. Never write a query this way.
var flawed = "SELECT Id, Reference, Total FROM Orders WHERE CustomerReference = '"
+ customerReference + "'";
// The same flaw with nicer syntax. Interpolation is concatenation.
var alsoFlawed = $"SELECT Id, Reference, Total FROM Orders WHERE CustomerReference = '{customerReference}'";
// CORRECT. The statement text is fixed, and the value travels separately.
const string query = """
SELECT Id, Reference, Total
FROM Orders
WHERE CustomerReference = @CustomerReference
""";
using var command = new SqlCommand(query, connection);
command.Parameters.Add("@CustomerReference", SqlDbType.NVarChar, 40).Value = customerReference;
using var reader = await command.ExecuteReaderAsync(ct);- In the first two versions the value is glued into the statement before it leaves your process. The database receives one string and parses the whole of it, so anything in the value that reads as SQL syntax becomes SQL syntax.
- Those two lines are here so you can recognise the shape in existing code: a quote character, then a concatenation or an interpolation hole, then another quote. That pattern anywhere near a database call is the thing to look for in review.
- In the corrected version the statement text is a constant. @CustomerReference is a placeholder, not a substitution — nothing is pasted into that string at any point.
- The value is sent to the database as a separate, typed argument. The engine parses the statement first and binds the value afterwards, so the value fills a slot in a finished plan. It is a value, always, whatever it contains.
- Declaring the type and length is worth the extra characters. Letting the driver infer them from each value produces a different inferred type per call, which can stop the engine reusing a cached plan and, when the inferred type does not match the column, can prevent an index being used at all.
- The triple-quoted string is a C# raw string literal. It lets the SQL keep its line breaks and indentation without escaping, which makes a long statement readable in code review.
-- Roughly what a parameterised call looks like on arrival:
-- statement text, parameter declarations and values kept apart.
EXEC sp_executesql
N'SELECT Id, Reference, Total FROM Orders WHERE CustomerReference = @CustomerReference',
N'@CustomerReference nvarchar(40)',
@CustomerReference = N'CR-00184';
-- FLAWED SHAPE. A stored procedure is not automatically safe. This one
-- builds a statement out of a value, which is the same flaw moved inside
-- the database.
CREATE PROCEDURE dbo.SearchOrdersFlawed @Reference nvarchar(40)
AS
BEGIN
DECLARE @sql nvarchar(max) =
N'SELECT Id, Reference FROM Orders WHERE Reference = ''' + @Reference + N'''';
EXEC (@sql);
END;
-- CORRECT. The text is still built dynamically, but the value stays a parameter.
CREATE PROCEDURE dbo.SearchOrders @Reference nvarchar(40)
AS
BEGIN
DECLARE @sql nvarchar(max) =
N'SELECT Id, Reference FROM Orders WHERE Reference = @Ref';
EXEC sp_executesql @sql, N'@Ref nvarchar(40)', @Ref = @Reference;
END;- The first statement shows the separation on arrival: the statement text, the parameter declarations, and the values, as three distinct things.
- Moving a query into a stored procedure is often described as a fix for this. It is not one by itself. A procedure that concatenates its parameter into a string and executes the result has exactly the problem that application code doing the same thing has.
- The corrected procedure still assembles its text at runtime — sometimes you need to, for a genuinely variable set of filters — but it passes the value to sp_executesql as a parameter instead of pasting it into the text.
- One limit to know: an identifier such as a table name, a column name or a sort direction cannot be a parameter. Parameters are values only. When the caller influences an identifier, map their input to a fixed set of known-good names in your own code and never pass the input through. Sorting is the usual case — accept 'oldest' or 'highest-value' and translate, rather than accepting a column name and a direction.
Where the risk sits with each common way of talking to the database:
| Safe by construction | Needs attention | |
|---|---|---|
| ADO.NET | SqlCommand with declared parameters | SqlCommand given a concatenated or interpolated string |
| Dapper | An anonymous object passed as param, from which Dapper creates parameters | A query string you assembled before handing it to Dapper |
| EF Core LINQ | Where clauses and the rest of LINQ, which EF translates to parameterised SQL | Nothing here. The translation does not paste values into the statement text |
| EF Core raw SQL | FromSql or FromSqlInterpolated with an interpolated string, where each hole becomes a parameter | FromSqlRaw or ExecuteSqlRaw given a string you concatenated yourself |
| Identifiers and sort order | Caller input mapped to a fixed set of allowed names in your code | A column name, table name or direction taken from the request |
Defence in depth. None of these replaces parameterised queries — they limit what a mistake somewhere else can reach:
- Give the application's database account only the rights it uses: read and write on its own tables, and no ability to drop them, read other databases, or create logins
- Use a separate read-only account for reporting, which is where dynamically assembled queries tend to live
- Keep schema changes on a different account used by migrations at deploy time, not by the running application
- Return a generic error to the client and keep the detail in your logs. A database message quoted back to the caller describes your schema to whoever provoked it
- Turn on the analyser rule your toolchain offers for string-concatenated SQL — CA2100 in .NET — and treat it as a build error. It is the cheapest check available and it never gets bored
- In code review, look for one specific shape: a quote character next to a plus sign or an interpolation hole, anywhere in code that reaches the database
Summary
- The cause is data placed where code goes: a value concatenated or interpolated into statement text
- Parameterised queries remove the risk structurally, because the statement is parsed before the value is bound
- Interpolation is concatenation, and a stored procedure that builds its own SQL has the same problem
- ORMs parameterise their generated queries; raw SQL escape hatches and dynamic identifiers are what to review
- Least privilege on the database account and an analyser rule for concatenated SQL limit what a mistake can reach
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Convert a search endpoint
An orders search endpoint builds its query by concatenating two values from the query string: a customer reference and a maximum total.
Rewrite it with parameters. Then answer this: would adding a strict format check on the customer reference have been an acceptable alternative fix?
Show solution
The rewrite is mechanical. The statement text becomes a constant with two placeholders, and both values are added as typed parameters. The maximum total is a decimal, so give it a decimal parameter rather than letting a string be converted somewhere along the way.
On the second question: no, and it is worth being clear why. A format check makes the specific route harder to misuse; it does not change the fact that data is being placed where code goes. The next field added to that query will not have a format check, or will have one that permits free text, and the flaw reappears with no code change that looks suspicious.
Validation and parameterisation also answer different questions. Validation asks whether you want this input at all — a maximum total of minus fifty is a business error worth rejecting regardless. Parameterisation makes the value's contents irrelevant to the statement's structure. Do both, and rely on the second one for this problem.
const string query = """
SELECT Id, Reference, Total
FROM Orders
WHERE CustomerReference = @CustomerReference
AND Total <= @MaximumTotal
ORDER BY PlacedOn DESC
""";
using var command = new SqlCommand(query, connection);
command.Parameters.Add("@CustomerReference", SqlDbType.NVarChar, 40).Value = customerReference;
command.Parameters.Add("@MaximumTotal", SqlDbType.Decimal).Value = maximumTotal;Think about it
Which of these is still a problem?
You have parameterised every value in the orders module. Three things remain: a sort column chosen from a dropdown, a page size from the query string, and a free-text delivery note that gets saved and later shown on a dashboard.
For each one, say whether parameterisation handled it, and what is left to do.
Show solution
The sort column is not handled, because it is an identifier rather than a value, and parameters only carry values. Map the dropdown's options to column names in your own code: a switch from 'newest' to PlacedOn, from 'highest-value' to Total, with an exception or a default for anything else. Never pass the incoming string into the statement, even when it arrived from a control you wrote.
The page size is a value, so a parameter covers the injection question completely. It still needs a range check, for a different reason: an unbounded page size is a way to make your database do an enormous amount of work per request. That is an availability concern, not an injection one, and a maximum of a few hundred settles it.
The delivery note is no longer a SQL problem at all. A parameter stores whatever the employee typed, faithfully, which is what you want. It becomes a different problem when the dashboard renders it into a page, and that is the next lesson — the same value, a different destination, a different fix.
The pattern to take away: parameters solve the value-into-statement problem completely and solve nothing else. Identifiers need an allow-list, quantities need range checks, and rendering needs encoding.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.