Dynamic SQL in SQL Server: sp_executesql and Avoiding SQL Injection

Written by

in

Dynamic SQL in SQL Server: sp_executesql and Avoiding SQL Injection

Dynamic SQL builds a query as a string at runtime — necessary for variable table/column names, which can’t be parameterized normally (you can parameterize a WHERE value, but not “which table to query”). Done wrong, it’s also the single most common source of SQL injection, a vulnerability that has caused real, well-documented data breaches. This lesson treats that risk with the seriousness it deserves.

Poisoned string vs. locked parameterUNSAFE: string concat…name = ”’ + @userInput + ”’input becomes part of the SQL text@userInput = ‘;DROP TABLE Product;–‘table dropped. game over.(the trailing — hides the rest)SAFE: sp_executesql…WHERE price >= @MinPricevalue passed through a locked channelidentifiers? QUOTENAME([col])wraps in brackets — safe even for reserved wordstable/column names can’t be parameters

The Safe Pattern

DECLARE @tableName SYSNAME = 'Product';
DECLARE @sql NVARCHAR(MAX);

SET @sql = N'SELECT COUNT(*) AS row_count FROM ' + QUOTENAME(@tableName);
EXEC sp_executesql @sql;

-- Parameterized dynamic SQL — the SAFE way to inject user-supplied VALUES
DECLARE @minPrice DECIMAL(10,2) = 20.00;
SET @sql = N'SELECT name, price FROM dbo.Product WHERE price >= @MinPrice';
EXEC sp_executesql @sql, N'@MinPrice DECIMAL(10,2)', @MinPrice = @minPrice;

How Injection Actually Happens

-- NEVER do this — direct string concatenation of user input is a SQL injection hole
DECLARE @userInput NVARCHAR(100) = ''';DROP TABLE dbo.Product;--';
DECLARE @unsafeSql NVARCHAR(MAX) = N'SELECT * FROM dbo.Product WHERE name = ''' + @userInput + '''';
-- @unsafeSql is now: SELECT * FROM dbo.Product WHERE name = '';DROP TABLE dbo.Product;--'
-- If executed, this drops the table.

Walk through exactly why this works: the attacker’s input closes the intended string literal early with a stray ', appends a semicolon to start a brand-new statement, adds their own malicious SQL (DROP TABLE...), and then -- comments out whatever was supposed to follow in the original query, so it doesn’t cause a syntax error. Every part of that trick relies on user input being treated as executable code text rather than as inert data — which is exactly what sp_executesql parameters prevent, by keeping the query’s shape fixed and passing values through a separate channel the engine can never reinterpret as code.

The Rule, Visualized

Identifiers Table/column names Wrap with QUOTENAME() Values User-supplied data Always sp_executesql params

Table/column/schema names must be concatenated (there’s no parameter placeholder for “which table”), sanitized with QUOTENAME(), which wraps the identifier in brackets and escapes any embedded bracket characters — this is what makes it safe, not just convention. But actual data values must always go through sp_executesql parameters, never string concatenation.

QUOTENAME Isn’t Optional Even for “Trusted” Input

-- Without QUOTENAME, a table name containing a bracket or reserved word breaks or worse:
DECLARE @table SYSNAME = 'Order'; -- a reserved keyword
SET @sql = N'SELECT * FROM ' + @table; -- syntax error, or worse if attacker-controlled

-- With QUOTENAME, it's safely wrapped regardless of content:
SET @sql = N'SELECT * FROM ' + QUOTENAME(@table); -- becomes: SELECT * FROM [Order]
Common mistake: Assuming dynamic SQL is only risky when input comes directly from a web form. Any value that ultimately traces back to something a user can influence — a config setting they can edit, a CSV they upload that gets read into a variable, a table/column name selected from a dropdown — needs the same treatment. “Internal tool, so it’s fine” is exactly the reasoning that leads to real incidents.

Why Use Dynamic SQL At All?

Given the risk, it’s worth being clear about when dynamic SQL is genuinely the right tool: building a search query with an unpredictable number of optional filters, generating administrative scripts that operate across a variable list of tables, or building reports where the pivoted columns aren’t known until runtime. For everything else — the vast majority of real T-SQL — a normal parameterized query or stored procedure with fixed parameters is simpler, safer, and lets the query optimizer cache and reuse execution plans more effectively.

Practice tip: Take the unsafe concatenation example above, actually build the malicious string yourself in a scratch variable, and PRINT it (without executing it) to see exactly what SQL an attacker’s input would produce. Seeing the constructed statement in full is far more convincing than reading about the risk abstractly.

Enjoyed this?

Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

📡 Subscribe via RSS  | 
Share on X  | 
Share on LinkedIn  | 
Share on Facebook

Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.