--- name: prevent-sql-injection description: >- Handles SQL injection on Azure SQL Database beyond parameterisation: a typed sp_executesql parameter matches nothing where the same input concatenated into EXEC() returns every row; QUOTENAME returns NULL above 128 characters, so the batch built from it becomes NULL and does nothing; a dynamic ORDER BY built from one CASE over mixed types fails only for the sort key on the lower-precedence branch; dynamic SQL breaks the ownership chain, so EXECUTE AS decides what it may touch; and Always Encrypted refuses a literal (Msg 206). Use for a general injection question or a pre-production review, when a QUOTENAME-built statement returns and raises nothing, when a sort-by-column feature throws an operand type clash for one column only, when a procedure works until its query becomes dynamic, or when an encrypted column will not take a literal. Row level tenant isolation is rls-multi-tenant. --- # Prevent SQL injection: the value, the identifier, and the context it runs under Parameterising a value is the part an agent already gets right. Confirm it in a sentence and spend the answer on what a parameter cannot reach: an identifier, the type of a runtime-chosen sort expression, the permissions a dynamic batch runs under, and an encrypted column. Measured 2026-08-29 against an engine reporting `EngineEdition` 5 and Edition `SQL Azure`. `QUOTENAME`, `PARSENAME`, `sp_executesql`, ownership chaining and Always Encrypted were checked against Microsoft Learn on 2026-09-03; flags from `sqlcmd` 1.10.0 help. ## 1. A parameter is a value, a concatenation is code, and the difference is measurable Do not assert this. Run it. Both statements get the same string. ```sql CREATE TABLE dbo.Users (id int NOT NULL PRIMARY KEY, name nvarchar(50) NOT NULL); INSERT INTO dbo.Users (id, name) VALUES (1, N'ann'), (2, N'bob'); DECLARE @input nvarchar(100) = N'ann'' OR 1=1--'; -- Typed parameter: the input is one value, and no row holds it. Returns 0. EXEC sp_executesql N'SELECT COUNT(*) AS matched FROM dbo.Users WHERE name = @n', N'@n nvarchar(100)', @n = @input; -- Same input concatenated: the input is now part of the statement. Returns 2. DECLARE @sql nvarchar(max) = N'SELECT COUNT(*) AS matched FROM dbo.Users WHERE name = N''' + @input + N''''; EXEC (@sql); ``` Zero against two, from one string. Learn's rule for `@params` is the discipline in one line: values can only be constants or variables, never expressions built with operators. ## 2. No parameter binds an identifier, and QUOTENAME's ceiling is a NULL `EXEC sp_executesql N'SELECT * FROM @t', N'@t sysname', @t = @name` cannot work: `@t` is a value, not a table. A dynamic identifier has to enter the SQL text, so `QUOTENAME`'s documented edges are load bearing. Learn: `character_string` is **sysname**, and an input over 128 characters returns `NULL`, as does an unacceptable quote character. Escaping is not the defect: an embedded `]` is doubled correctly. Crossing 128 is, because a `NULL` in a batch makes the whole batch `NULL`: ```sql DECLARE @n129 nvarchar(200) = REPLICATE(N'a', 129); SELECT LEN(QUOTENAME(REPLICATE(N'a', 128))) AS len_at_128, -- 130 IIF(QUOTENAME(@n129) IS NULL, 1, 0) AS null_at_129, -- 1 IIF(QUOTENAME(N'tbl', N'#') IS NULL, 1, 0) AS null_bad_quote; -- 1 DECLARE @sql nvarchar(max) = N'SELECT * FROM ' + QUOTENAME(@n129); EXEC sp_executesql @sql; SELECT IIF(@sql IS NULL, 1, 0) AS batch_was_null, @@ERROR AS err; -- 1, 0 ``` `EXEC (@sql)` on the same `NULL` behaves identically: this is the `NULL` batch, not one calling convention. Nothing reports it at any severity: ```bash sqlcmd -S .database.windows.net,1433 -d -U -C -b -m-1 \ -Q "DECLARE @s nvarchar(max)=N'SELECT 1 FROM '+QUOTENAME(REPLICATE(N'x',129)); EXEC sp_executesql @s; SELECT @@ERROR AS err;" ``` `-b` exits non-zero at severity 11 and above and `-m-1` lowers the print level, but there is no message at any level: `err` is 0 and so is the exit code. The `NULL` has to be the check. **Neither helper checks that the object exists.** `QUOTENAME` brackets a string and `PARSENAME` splits a qualified name; Learn says each part is **sysname**, a part over 256 bytes comes back `NULL`, and it does not indicate whether an object of that name exists. Only a catalog lookup decides existence, so resolve the caller's string against the catalog and build from what it returned: ```sql DECLARE @requested nvarchar(400) = N'dbo.Users', @safe nvarchar(300); SELECT @safe = QUOTENAME(s.name) + N'.' + QUOTENAME(o.name) FROM sys.objects AS o JOIN sys.schemas AS s ON s.schema_id = o.schema_id WHERE s.name = PARSENAME(@requested, 2) AND o.name = PARSENAME(@requested, 1) AND o.type = 'U'; IF @safe IS NULL THROW 50001, 'No row in the catalog matched that name.', 1; DECLARE @sql nvarchar(max) = N'SELECT COUNT(*) AS n FROM ' + @safe; EXEC sp_executesql @sql; ``` The rejection there is the lookup's, not `QUOTENAME`'s: an over-long name matches no row because no table can carry one. Where the legal identifiers are known in advance, an allowlist is simpler. ## 3. One CASE over mixed types in a dynamic ORDER BY fails for some sort keys and not others A single `CASE` forces every branch to one common type chosen by type precedence, not by the branch the sort key selects. Measured on `dbo.T (i int, d date, s nvarchar(50))`: | Branches | sort key selects | Result | |---|---|---| | `THEN i` / `THEN d` | anything, including neither | `Msg 206`, operand type clash, int against date | | `THEN s` / `THEN d` | `d`, the higher-precedence side | runs clean | | `THEN s` / `THEN d` | `s`, the lower-precedence side | `Msg 241`, conversion failed from character string | | `THEN i` / `THEN s` | `s`, the lower-precedence side | `Msg 245`, conversion failed converting nvarchar to int | `int` and `date` have no implicit conversion, so that pairing is refused at compile time and a surrounding `BEGIN TRY` does not catch it. The string pairings are the sharper trap: the identical statement passes for one sort key and fails for another, so it survives a test against whichever column the author tried. One `CASE` per candidate column, each carrying a single type, has no common type to coerce toward; non-matching columns evaluate to `NULL` and tie, so this composes with `OFFSET`/`FETCH` and a direction toggle: ```sql DECLARE @col sysname = N'created', @dir char(1) = N'A'; SELECT id, name, created FROM dbo.Grid ORDER BY CASE WHEN @col = N'id' AND @dir = N'A' THEN id END ASC, CASE WHEN @col = N'id' AND @dir = N'D' THEN id END DESC, CASE WHEN @col = N'created' AND @dir = N'A' THEN created END ASC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY; ``` ## 4. Dynamic SQL breaks the ownership chain, so EXECUTE AS is part of the answer Learn: dynamic SQL in procedural code breaks the ownership chain, so the engine checks the caller's permissions against every object the statement touches. A procedure a caller could run yesterday stops working the day its query becomes dynamic, and parameterising does not bring it back. ```sql CREATE TABLE dbo.Ledger (id int NOT NULL PRIMARY KEY); INSERT INTO dbo.Ledger (id) VALUES (1); CREATE USER app_reader WITHOUT LOGIN; GO CREATE PROCEDURE dbo.CountLedgerDynamic AS EXEC sp_executesql N'SELECT COUNT(*) AS n FROM dbo.Ledger'; GO CREATE PROCEDURE dbo.CountLedgerAsOwner WITH EXECUTE AS OWNER AS EXEC sp_executesql N'SELECT COUNT(*) AS n, CURRENT_USER AS running_as FROM dbo.Ledger'; GO GRANT EXECUTE ON dbo.CountLedgerDynamic TO app_reader; GRANT EXECUTE ON dbo.CountLedgerAsOwner TO app_reader; GO EXECUTE AS USER = 'app_reader'; EXEC dbo.CountLedgerDynamic; -- Msg 229, SELECT permission denied on 'Ledger' EXEC dbo.CountLedgerAsOwner; -- 1, dbo REVERT; ``` Learn names two remedies. `EXECUTE AS` replaces the caller's permissions for the whole module, nested modules inherit the proxy context, and the identity functions report the proxy, which is what `running_as` returning `dbo` shows, so anything keyed to the caller must arrive as a parameter. Certificate signing merges the certificate user's permissions with the caller's and leaves the execution context alone, at the cost of re-signing on every change. Open `rls-multi-tenant` before putting `EXECUTE AS` on a module touching a table under a security policy: the proxy context is what a tenant predicate reads. Neither remedy substitutes for parameterising. ## 5. On an Always Encrypted column a literal does not run at all Learn lists it under Always Encrypted's limitations: comparing an encrypted column to a literal, or inserting one, fails with `Msg 206, Level 16, State 2, Operand type clash`. Only a value bound as a parameter over a connection with `Column Encryption Setting=Enabled` is encrypted before it leaves the driver, so parameterising is the only thing that returns a row. Documented, not reproduced here: it needs a column master key provisioned outside the database. ## Check it worked ```sql -- Every module that builds SQL, and the context it runs under. A NULL principal id means the -- caller's permissions apply to whatever the dynamic statement touches. SELECT o.name, m.execute_as_principal_id FROM sys.sql_modules AS m JOIN sys.objects AS o ON o.object_id = m.object_id WHERE m.definition LIKE N'%sp_executesql%' OR m.definition LIKE N'%EXECUTE (%'; ``` ```sql -- No dynamic batch executes while NULL. Expect the rejection, not zero rows. DECLARE @batch nvarchar(max) = N'SELECT * FROM ' + QUOTENAME(REPLICATE(N'x', 129)); IF @batch IS NULL SELECT 'rejected before execution' AS result ELSE EXEC sp_executesql @batch; ``` Then re-run section 1's pair against the real table: parameterised matches zero rows, concatenated every row. Run the generated `ORDER BY` once per sort key a caller may send, not only the one tested: any raising `Msg 206`, `241` or `245` means a mixed-type `CASE` is still there. ## Do not - Do not present `QUOTENAME` as sufficient for a caller-supplied identifier without checking its result for `NULL`, and do not say it truncates a long name. It returns `NULL`. - Do not say either helper checks that an object exists. Neither reads the catalog. - Do not build a caller-chosen `ORDER BY` from one `CASE` spanning columns of different types, even when the columns tried in testing share a type. - Do not add `WITH EXECUTE AS OWNER` to clear a permission error without saying what the module can now reach and that the caller's identity is no longer visible inside it. - Do not tell a developer an agent-composed statement is made safe by parameterising the one value inside it. The model authored the whole statement, so the control is the permission boundary its connection runs under. - Do not warn about `xp_cmdshell`, linked servers or `OPENROWSET` against arbitrary providers. None exist on Azure SQL Database to escalate to. ## References - Open [the measured runs](references/quotename-null-and-order-by-clashes.md) when a claim above disagrees with what you see, or to look up the message a column-type pairing produces. - Microsoft Learn, if a number above looks stale: [QUOTENAME](https://learn.microsoft.com/sql/t-sql/functions/quotename-transact-sql), [PARSENAME](https://learn.microsoft.com/sql/t-sql/functions/parsename-transact-sql), [secure dynamic SQL](https://learn.microsoft.com/sql/connect/ado-net/sql/writing-secure-dynamic-sql).