Security: logins, roles, permissions and SQL injection
Who may connect and what they may do: logins and database users, GRANT, DENY and REVOKE, roles, testing with EXECUTE AS, ownership chaining through procedures and views, least privilege for an application, and SQL injection: how it works, what it can do, and how parameters and QUOTENAME stop it.
Logins and users
A login lets someone connect to the server. It is either a Windows account or group ( CREATE LOGIN [SHOP\Ana] FROM WINDOWS) or a SQL Server login with its own password.A user gives a login access to one database. A login with no user in BuildMart can connect to the server but cannot use BuildMart.
USE master;CREATE LOGIN shop_web WITH PASSWORD = N'use-a-long-random-password-here';GOUSE BuildMart;CREATE USER shop_web FOR LOGIN shop_web;CREATE USER Ana WITHOUT LOGIN; -- works in the shopCREATE USER Raj WITHOUT LOGIN; -- writes reports SELECT name, type_desc, authentication_type_descFROM sys.database_principalsWHERE name IN ('Ana', 'Raj');name type_desc authentication_type_desc ---- --------- ------------------------ Ana SQL_USER NONE Raj SQL_USER NONE
GRANT, and testing with EXECUTE AS
GRANT gives a permission on an object. EXECUTE AS USER lets you, as an administrator, run statements as if you were that user, and REVERT switches back:
GRANT SELECT ON dbo.Products TO Ana; EXECUTE AS USER = 'Ana';SELECT USER_NAME() AS RunningAs, COUNT(*) AS Products FROM dbo.Products;SELECT COUNT(*) AS Customers FROM dbo.Customers;REVERT;RunningAs Products --------- -------- Ana 12 Msg 229, Level 14, State 5, Line 5 The SELECT permission was denied on the object 'Customers', database 'BuildMart', schema 'dbo'.
| Grant on | Example | Covers |
|---|---|---|
GRANT SELECT ON dbo.Customers (FirstName, City) TO Raj | ||
GRANT SELECT, INSERT ON dbo.Orders TO Ana | ||
GRANT SELECT ON SCHEMA::dbo TO Raj | ||
GRANT CREATE TABLE TO Raj |
Roles
CREATE ROLE Reporting;GRANT SELECT ON SCHEMA::dbo TO Reporting;DENY SELECT ON dbo.Customers (Email, Phone) TO Reporting;ALTER ROLE Reporting ADD MEMBER Raj; EXECUTE AS USER = 'Raj';SELECT TOP (2) FirstName, City FROM dbo.Customers ORDER BY CustomerId;SELECT TOP (2) FirstName, Email FROM dbo.Customers ORDER BY CustomerId;REVERT;FirstName City --------- ----- Asha Leeds Ben York Msg 230, Level 14, State 1, Line 8 The SELECT permission was denied on the column 'Email' of the object 'Customers', database 'BuildMart', schema 'dbo'.
REVOKE is different: it removes a GRANT or DENY, going back to “not mentioned”.
db_datareader can read every table and db_datawriter can change every table; db_owner can do anything in the database. At server level, sysadmin can do anything at all. Use them sparingly: an application that connects as db_owner or sysadmin turns every bug into a disaster.
SELECT r.name AS RoleName, m.name AS MemberFROM sys.database_role_members AS rmJOIN sys.database_principals AS r ON r.principal_id = rm.role_principal_idJOIN sys.database_principals AS m ON m.principal_id = rm.member_principal_idWHERE m.name IN ('Ana', 'Raj'); SELECT pr.name AS Grantee, pe.state_desc, pe.permission_name, COALESCE(OBJECT_NAME(pe.major_id), pe.class_desc) AS OnObject, COL_NAME(pe.major_id, pe.minor_id) AS OnColumnFROM sys.database_permissions AS peJOIN sys.database_principals AS pr ON pr.principal_id = pe.grantee_principal_idWHERE pr.name IN ('Ana', 'Raj', 'Reporting')ORDER BY Grantee, OnObject, OnColumn;RoleName Member --------- ------ Reporting Raj Grantee state_desc permission_name OnObject OnColumn --------- ---------- --------------- --------- -------- Ana GRANT CONNECT DATABASE NULL Ana GRANT SELECT Products NULL Raj GRANT CONNECT DATABASE NULL Reporting DENY SELECT Customers Email Reporting DENY SELECT Customers Phone Reporting GRANT SELECT SCHEMA NULL
Procedures as the front door: ownership chaining
CREATE OR ALTER PROCEDURE dbo.GetCustomerOrders @CustomerId intASSELECT o.OrderId, o.OrderDate, o.StatusFROM dbo.Orders AS oWHERE o.CustomerId = @CustomerIdORDER BY o.OrderId;GOGRANT EXECUTE ON dbo.GetCustomerOrders TO Ana; EXECUTE AS USER = 'Ana';EXEC dbo.GetCustomerOrders @CustomerId = 1;SELECT COUNT(*) FROM dbo.Orders;REVERT;OrderId OrderDate Status
------- ---------- -------
1 2024-02-03 Shipped
3 2024-03-01 Shipped
Msg 229, Level 14, State 5, Line 5
The SELECT permission was denied on the object 'Orders', database 'BuildMart', schema 'dbo'.SQL injection
CREATE OR ALTER PROCEDURE dbo.FindCustomers @LastName nvarchar(100)ASBEGIN DECLARE @sql nvarchar(max) = N'SELECT CustomerId, FirstName, LastName, City FROM dbo.Customers ' + N'WHERE LastName = ''' + @LastName + N''''; PRINT @sql; EXEC (@sql);END;GOEXEC dbo.FindCustomers @LastName = N'Patel';SELECT CustomerId, FirstName, LastName, City FROM dbo.Customers WHERE LastName = 'Patel'
CustomerId FirstName LastName City
---------- --------- -------- -----
1 Asha Patel Leeds-- to comment out the rest:
EXEC dbo.FindCustomers @LastName = N'x'' OR 1 = 1 --';SELECT CustomerId, FirstName, LastName, City FROM dbo.Customers WHERE LastName = 'x' OR 1 = 1 --'
CustomerId FirstName LastName City
---------- --------- -------- ---------
1 Asha Patel Leeds
2 Ben Carter York
3 Chloe Evans Leeds
4 Dev Singh Bradford
5 Ella Brown NULL
6 Farid Khan York
7 Grace Lee Leeds
8 Zoë Müller HarrogateOR 1 = 1 matched every customer. With UNION the attacker can read any other table the account can see, here the customers’ private contact details:
EXEC dbo.FindCustomers @LastName = N'x'' UNION SELECT CustomerId, Email, Phone, NULL FROM dbo.Customers --';SELECT CustomerId, FirstName, LastName, City FROM dbo.Customers WHERE LastName = 'x' UNION SELECT CustomerId, Email, Phone, NULL FROM dbo.Customers --'
CustomerId FirstName LastName City
---------- ----------------- ------------- ----
1 asha@example.com 0113 496 0001 NULL
2 NULL 01904 555 010 NULL
3 chloe@example.com NULL NULL
4 dev@example.com 01274 555 220 NULL
5 ella@example.com NULL NULL
6 farid@example.com 01904 555 777 NULL
7 NULL NULL NULL
8 zoe@example.com 01423 555 123 NULLEXEC dbo.FindCustomers @LastName = N'x''; UPDATE dbo.Products SET Price = 0.01; --'; SELECT TOP (3) Name, Price FROM dbo.Products ORDER BY ProductId;SELECT CustomerId, FirstName, LastName, City FROM dbo.Customers WHERE LastName = 'x'; UPDATE dbo.Products SET Price = 0.01; --' CustomerId FirstName LastName City ---------- --------- -------- ---- Name Price ---------------- ----- Claw hammer 0.01 Cordless drill 0.01 Tape measure 8 m 0.01
The fix: parameters
sp_executesql takes the statement, the parameter declarations and the values:
CREATE OR ALTER PROCEDURE dbo.FindCustomers @LastName nvarchar(100)ASBEGIN EXEC sp_executesql N'SELECT CustomerId, FirstName, LastName, City FROM dbo.Customers WHERE LastName = @LastName', N'@LastName nvarchar(100)', @LastName = @LastName;END;GOEXEC dbo.FindCustomers @LastName = N'Patel';EXEC dbo.FindCustomers @LastName = N'x'' OR 1 = 1 --';EXEC dbo.FindCustomers @LastName = N'O''Brien';CustomerId FirstName LastName City
---------- --------- -------- -----
1 Asha Patel Leeds
CustomerId FirstName LastName City
---------- --------- -------- ----
CustomerId FirstName LastName City
---------- --------- -------- ----SELECT … WHERE LastName = @LastName in the procedure is parameterized already. Use dynamic SQL only when the shape of the query must change.
When the SQL itself must change: QUOTENAME and allow-lists
QUOTENAME, which puts it in square brackets and doubles any ] inside it, so it can only ever be read as a name:
CREATE OR ALTER PROCEDURE dbo.ListProducts @SortBy sysnameASBEGIN IF @SortBy NOT IN (N'Name', N'Price', N'Stock') THROW 50001, 'SortBy must be Name, Price or Stock.', 1; DECLARE @sql nvarchar(max) = N'SELECT TOP (3) Name, Stock FROM dbo.Products ORDER BY ' + QUOTENAME(@SortBy) + N' DESC'; EXEC sp_executesql @sql;END;GOEXEC dbo.ListProducts @SortBy = N'Stock';EXEC dbo.ListProducts @SortBy = N'Stock; DROP TABLE dbo.Orders';Name Stock ---------------- ----- Timber 2x4 2.4 m 500 Cement 25 kg 300 Sand 25 kg 220 Msg 50001, Level 16, State 1, Procedure dbo.ListProducts, Line 5 SortBy must be Name, Price or Stock.
Least privilege
One login per application, never saor a sysadmin; Windows or managed identities where possible, so there is no password to leak.Applications get EXECUTE on their procedures (or SELECT on views), not db_owner. People get roles that match their jobs; DENY the sensitive columns. Every query built from input is parameterized: in procedures, and in application code (the next lesson shows SqlParameterin C#).Names that must be dynamic are checked against an allow-list and wrapped in QUOTENAME.
Show a hint
Show the solution
CREATE ROLE ShopStaff;GRANT EXECUTE ON dbo.GetCustomerOrders TO ShopStaff;GRANT SELECT ON dbo.Products TO ShopStaff; CREATE USER Lee WITHOUT LOGIN;ALTER ROLE ShopStaff ADD MEMBER Lee; EXECUTE AS USER = 'Lee';EXEC dbo.GetCustomerOrders @CustomerId = 4;SELECT COUNT(*) FROM dbo.Customers;REVERT;OrderId OrderDate Status
------- ---------- -------
4 2024-03-15 Shipped
7 2024-05-05 Shipped
Msg 229, Level 14, State 5, Line 10
The SELECT permission was denied on the object 'Customers', database 'BuildMart', schema 'dbo'.Remember
A login lets someone connect to the server; a user maps it into one database. Prefer Windows logins. GRANT gives a permission, DENY forbids it and always wins, REVOKE removes either. Grant to roles, not people; use schema-level grants and column-level DENY where they fit. EXECUTE AS USER … REVERT tests what a user can do; users WITHOUT LOGIN are handy for tests. Ownership chaining lets a procedure or view use tables the caller cannot touch; dynamic SQL inside breaks the chain. SQL injection: input glued into SQL text can change the statement. It can read, change or destroy data. Fix it with parameters (sp_executesql in T-SQL, SqlParameter in C#); for names, an allow-list plus QUOTENAME. Least privilege: applications get EXECUTE on procedures, never db_owner or sysadmin.