← Open in the full interactive course (progress tracking, search & more)

One line of string concatenation is the difference between "it works on my machine" and a headline about a data breach.

Look at this login check and see if anything feels wrong:

string sql = "SELECT * FROM Users WHERE Username = '" + username + "' AND Password = '" + password + "'";

It compiles. It runs. In a demo, typing a real username and password logs you in exactly as expected. It looks completely harmless — which is precisely what makes it dangerous. Now imagine someone types this into the username field instead of a real name:

' OR '1'='1

The string your code builds becomes:

SELECT * FROM Users WHERE Username = '' OR '1'='1' AND Password = ''

'1'='1' is always true. That WHERE clause no longer filters for a specific user — it matches every row in the table. Depending on how the rest of the code is written, that single line of typed text can log the attacker in as the first user in the database, often an administrator, without knowing a single real password.

This is SQL injection — one of the oldest, simplest, and still most common serious vulnerabilities in software. It happens the moment untrusted text is pasted directly into a SQL string instead of being kept separate from it. It is entirely, completely preventable, and the fix is not complicated.

In this lesson, you'll learn exactly why building SQL by string concatenation is dangerous, how SqlParameter and parameterized queries close that door completely, and why parameters are non-negotiable — not a "best practice you can skip when you're in a hurry."

What Is It?

The Simple Explanation

A parameterized query is a SQL statement written with placeholders — @username, @id — instead of the real values baked directly into the text. The actual values are attached separately, as data, through a Parameters collection. The database engine never mixes the two: the SQL text is always treated as instructions, and every parameter value is always treated as pure data, no matter what characters it contains.

The Technical Definition

SqlParameter represents one named value bound to a placeholder in a command's CommandText. You add parameters to a DbCommand's Parameters collection — either quickly with AddWithValue(name, value), or explicitly with a constructed SqlParameter that also specifies its SQL data type and size:

command.CommandText = "SELECT * FROM Users WHERE Username = @username"; // Quick form — the provider infers the SQL type from the .NET value command.Parameters.AddWithValue("@username", username); // Explicit form — you control the exact SqlDbType and size command.Parameters.Add(new SqlParameter("@username", SqlDbType.NVarChar, 50) { Value = username });

Notice what's different from the hook example: username never touches the SQL string at all. It's handed to the database driver as a separate piece of data, tagged with the placeholder name it belongs to.

Why Does It Exist?

The Problem — String-Built SQL Lets Data Rewrite Your Instructions

When you build a SQL string by concatenating user-supplied text directly into it, you are asking the database to treat that text as part of the command itself. SQL has no built-in way to tell "a value that happens to contain a quote" apart from "a quote that ends the value and starts new SQL." If an attacker's input contains SQL syntax — a stray ', a ;, an OR — that syntax is executed exactly as if you had typed it yourself.

This isn't a theoretical edge case. It's one of the most exploited vulnerabilities in the history of web applications, and it doesn't require a sophisticated attacker — the ' OR '1'='1 trick from the hook is one of the first things anyone probing a login form tries.

The Solution — Separate the Instructions from the Data, Permanently

Parameters fix this at the root, not by trying to detect or block "bad" input. The SQL text with its placeholders is sent to the database once, as fixed instructions. The values are sent alongside it as a separate, typed data payload. The database driver — and the database engine itself — never re-parses a parameter's value as SQL syntax, no matter what it contains. A parameter value of ' OR '1'='1 is just a nineteen-character string being searched for, literally, as a username. It matches nothing, because no username in the table actually contains that text.

String Concatenation

Parameterized Query

Big Picture

TWO PATHS FOR THE SAME MALICIOUS INPUT
Attacker types into the username field
' OR '1'='1
Path A — String concatenation
Path B — Parameterized query

Same attacker, same input, same code path up to the database call — completely different outcome, because of where the data was allowed to travel.

How It Works

WRITING A PARAMETERIZED COMMAND, STEP BY STEP
Step 1 — Write the SQL with named placeholders, never with interpolated values
command.CommandText = "SELECT Id, Email FROM Users WHERE Username = @username AND Password = @password";
Step 2 — Add one parameter per placeholder
command.Parameters.AddWithValue("@username", username);
command.Parameters.AddWithValue("@password", hashedPassword);
Step 3 — Execute normally
Step 4 — The database substitutes values into the compiled query plan, never re-parsing them as text

Simple Example

Vulnerable — string concatenation

public async Task<User?> FindUserAsync(SqlConnection connection, string username) { await using SqlCommand command = connection.CreateCommand(); // NEVER DO THIS — username is pasted directly into the SQL text command.CommandText = $"SELECT Id, Email FROM Users WHERE Username = '{username}'"; await using SqlDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? new User(reader.GetInt32(0), reader.GetString(1)) : null; }

Even the C# 14 $"..." interpolated string syntax doesn't help here — it still glues username directly into the SQL text before it's ever sent to the database. Interpolation is a C# string feature; it has no idea it's building SQL, and no idea that username came from outside the application.

Safe — parameterized

public async Task<User?> FindUserAsync(SqlConnection connection, string username) { await using SqlCommand command = connection.CreateCommand(); command.CommandText = "SELECT Id, Email FROM Users WHERE Username = @username"; command.Parameters.AddWithValue("@username", username); await using SqlDataReader reader = await command.ExecuteReaderAsync(); return await reader.ReadAsync() ? new User(reader.GetInt32(0), reader.GetString(1)) : null; }

The SQL text is now a fixed, unchanging string — it never varies no matter what username contains. That's the whole fix: the shape of the query stops depending on user input.

Real-World Example

Consider an e-commerce site's product search box, backed by this vulnerable handler:

string sql = $"SELECT Id, Name, Price FROM Products WHERE Name LIKE '%{searchTerm}%'";

A normal search for "headphones" works fine. But an attacker can type a search term like:

x'; DROP TABLE Products; --

Depending on the database and driver's exact behavior, this can terminate the intended query and append a second, entirely different statement — one that deletes the entire product catalog. The -- comments out anything left over so the malformed SQL still "parses." This is not a hypothetical: '; DROP TABLE ...; -- is a textbook SQL injection payload that has taken down real production databases.

With a parameterized query, that same string simply becomes the literal text the engine searches for inside product names — it matches nothing, and the Products table is never touched:

command.CommandText = "SELECT Id, Name, Price FROM Products WHERE Name LIKE '%' + @searchTerm + '%'"; command.Parameters.AddWithValue("@searchTerm", searchTerm);

Notice the % wildcards for LIKE are built in SQL ('%' + @searchTerm + '%'), not by wrapping the parameter value itself in % inside C# string interpolation — searchTerm is still never concatenated into the command text.

Analogy

A Form With Locked Blanks, Not a Blank Sheet of Paper

String concatenation is like handing someone a blank sheet of paper and asking them to write down whatever the customer says, word for word, then acting on the entire page as instructions. If the customer says "ignore the previous order and give me everything in the warehouse for free," and it gets written down verbatim, that's now part of the instructions.

A parameterized query is a printed form with sealed, labeled blanks: Name: ____, Amount: ____. Whatever the customer writes in the "Name" blank is filed under "Name" — even if they write "ignore this order," it's just a strange-looking name, not a new instruction. The form's structure was decided in advance and printed before the customer ever showed up; nothing they write can add a new line to it.

Under the Hood

WHAT HAPPENS ON THE WIRE AND ON THE SERVER
1. The provider sends a parameterized RPC, not a plain-text query
2. The query plan is compiled once, values are substituted after
3. A useful side effect — plan reuse

Common Confusion

1. "I only need to parameterize input that comes from a public website"

No — parameterize every value that isn't a literal, hard-coded constant you typed yourself, regardless of source. Internal admin tools, scheduled jobs reading from a file, values from another internal service — all of it should go through parameters. "Trusted" sources have a way of becoming untrusted the moment someone else starts feeding them, or the moment that internal tool gets exposed one layer further than you expected.

2. "Escaping quotes manually is just as safe as parameters"

Manually replacing ' with '' (or similar) is a well-known, historically leaky approach — different database engines, encodings, and edge cases (like certain multi-byte character sequences) have repeatedly defeated hand-rolled escaping logic in real-world CVEs. Parameters don't rely on getting escaping rules right at all; the value never enters the SQL text in the first place, so there's nothing to escape.

3. "Parameters can protect a dynamic table or column name"

They can't — parameters bind to values in a WHERE, SET, or VALUES clause, not to SQL identifiers like table names, column names, or ORDER BY targets. SELECT * FROM @tableName is not valid, parameterizable SQL. If a table or column name genuinely needs to vary based on input, validate it against a fixed allowlist of known-safe names in your C# code before building that part of the SQL string.

Common Mistakes

Mistake 1 — Using string interpolation to build SQL

command.CommandText = $"SELECT * FROM Orders WHERE Id = {orderId}"; — even though orderId is an int here and feels "safe," this habit is exactly how injection bugs creep into a codebase the day someone copies this pattern for a string value. Always use a placeholder and a parameter, with no exceptions, so the safe pattern is the only pattern anyone on the team ever sees.

Mistake 2 — "Sanitizing" input with a blocklist of dangerous words

Stripping out or rejecting the word DROP or the character ' before concatenating. Blocklists are famously easy to bypass with encoding tricks, casing, or characters the author didn't think of, and they still leave the string-concatenation vulnerability structurally intact. Use parameters, which make the entire class of attack impossible rather than trying to filter it out case by case.

Mistake 3 — Trusting ORMs blindly while still hand-writing raw SQL elsewhere

Using EF Core (which parameterizes automatically) for most of the app, but dropping down to a raw, string-concatenated SQL string for "just this one report query." That one query carries the exact same risk as any other ADO.NET string concatenation. Even raw SQL executed through EF Core's FromSqlInterpolated or a manual DbCommand must use parameters — the ORM doesn't protect code that bypasses it.

Mistake 4 — Forgetting an explicit type/size on AddWithValue for performance-sensitive code

AddWithValue infers the SQL type and size from the .NET value at runtime, which can occasionally cause SQL Server to generate a less efficient query plan for a given parameter, or to fail to reuse a cached plan across calls with different string lengths. This is a performance nuance, not a security one — AddWithValue is just as safe against injection as the explicit form. For hot-path, high-traffic queries, consider the explicit SqlParameter constructor with a fixed SqlDbType and size; for everyday code, AddWithValue is fine and still fully protects against injection.

When Should I Use It?

Rule of thumb: Every single value that flows into a SQL statement — no exceptions, no "just this once," no "it's only internal" — goes through a parameter. There is no scenario in normal application development where string-concatenating a value into SQL is the right call. The only thing parameters genuinely can't handle is a dynamic identifier (table/column/sort-direction) — for that narrow case, validate the input against a fixed, hard-coded allowlist in C# before it touches the SQL string.

Mental Model

SQL text = the fixed instructions, decided by you, in advance.
Parameter value = data filed into a labeled blank — never re-read as instructions.
String concatenation = letting the customer write directly on the instruction sheet.

Remember: if a value can change based on who's using the app, it belongs in a parameter — not in the SQL string.

Key Takeaway


Check Your Understanding

This is a security-critical lesson, so take these seriously — being able to recognize a SQL injection vulnerability on sight is one of the most valuable habits you can build as a developer.

1. Why is command.CommandText = $"SELECT * FROM Users WHERE Username = '{username}'"; dangerous, even if most users will type a normal username?

Show answer

Correct: B

Why B is correct: Because the value is pasted directly into the SQL text, the database cannot distinguish "a username that happens to contain a quote" from "new SQL syntax." An attacker-controlled string can therefore inject its own logic into the query.

Why A is incorrect: The core issue is security, not performance — although parameterized queries can also enable query plan reuse, that's a secondary benefit, not the main reason to use them.

Why C is incorrect: String interpolation is a normal, current C# feature — the problem isn't the syntax used to build the string, it's using any string-building approach to construct SQL from untrusted input.

Why D is incorrect: The vulnerability has nothing to do with character set — it's about whether SQL-meaningful characters like ' can end up inside the executed command text.

Reinforcement: Any value pasted into a SQL string, in any form of string-building, can carry SQL syntax along with it.

2. A user types ' OR '1'='1 into a search box that's protected by a parameterized query: WHERE Name = @searchTerm. What happens?

Show answer

Correct: C

Why C is correct: The parameter's value is sent to the database as data, tagged to the @searchTerm placeholder — it is never re-parsed as SQL syntax. The database performs a literal, exact-text search for a name equal to that entire 13-character string, which won't match any real product name.

Why A is incorrect: That's what happens with string concatenation, which is exactly the failure mode parameters prevent.

Why B is incorrect: There's no syntax error — the query is perfectly valid SQL; the parameter value is just an unusual (but harmless) string to search for.

Why D is incorrect: Parameters aren't validated or rejected based on content — they don't need to be, since their content can never become executable SQL in the first place.

Reinforcement: Parameterization doesn't "detect and block" malicious-looking input — it makes the entire concept of "malicious SQL input" meaningless for that value.

3. Which of these can a SQL parameter correctly protect?

Show answer

Correct: B

Why B is correct: Parameters bind to values used in comparisons, inserts, and updates — a value slot in the SQL, not a structural part of the statement. WHERE CustomerId = @id is exactly the shape parameters are built for.

Why A is incorrect: Table names are SQL identifiers, not values — FROM @tableName isn't valid parameterizable SQL. A dynamic table name needs to be validated against a hard-coded allowlist in C# instead.

Why C is incorrect: Column names in ORDER BY are identifiers too, for the same reason as A — they can't be parameters.

Why D is incorrect: ASC/DESC are SQL keywords, part of the statement's structure, not data values — they also can't be parameterized and need allowlist validation if they vary at runtime.

Reinforcement: Parameters replace values, never table names, column names, or keywords — those structural pieces need a different defense: validating against a known-safe, hard-coded set of options.

4. A developer says: "I don't need to parameterize this query — it only reads a value from an internal configuration file that only my team edits." Is this reasoning sound?

Show answer

Correct: B

Why B is correct: Treating parameterization as optional for "trusted" sources is exactly how injection bugs slip into codebases — the source of a value can change over time (a config file that later gets exposed to a settings UI, an internal tool that later gets more users) while the vulnerable code path stays unchanged. Parameterizing every non-constant value costs nothing and removes this whole category of risk.

Why A is incorrect: Limiting the practice to "public user input" is the exact reasoning that leaves internal tools and less-obvious code paths vulnerable.

Why C is incorrect: Where a file is stored has no bearing on whether its contents could ever contain SQL-meaningful characters — the risk isn't about source control at all.

Why D is incorrect: The point isn't that config files are inherently untrustworthy — it's that trust in a source is not a substitute for the discipline of parameterizing values as a default habit.

Reinforcement: Make parameterization a default, unconditional habit for every SQL value — never a judgment call based on how trustworthy a value's source currently seems.

5. What is the primary difference between AddWithValue and constructing an explicit SqlParameter with a specified SqlDbType and size?

Show answer

Correct: B

Why B is correct: Both approaches send the value to the database separately from the SQL text, so both are equally immune to SQL injection through that parameter. The difference is a performance nuance: AddWithValue infers the type/size from the .NET value, which can occasionally lead to less efficient plan reuse compared to an explicitly typed and sized parameter.

Why A is incorrect: This is a common misconception — AddWithValue is fully protected against injection. The distinction between the two approaches is about performance, not security.

Why C is incorrect: Both forms of adding a parameter work with any execution method (ExecuteReader, ExecuteNonQuery, ExecuteScalar) — there's no such restriction.

Why D is incorrect: AddWithValue works with any .NET value type the provider can map to a SQL type — ints, dates, decimals, and more, not just strings.

Reinforcement: Don't confuse the type-inference convenience trade-off of AddWithValue with a security gap — there isn't one. Both forms of parameterization close the injection vulnerability equally.

You now understand exactly why parameterized queries are non-negotiable, and how they eliminate SQL injection at the root. Next up: keeping multi-step database operations safe with transactions.


dotnetmadeeasy.com — Learn C# and .NET, the right way.