SQL Server Security Best Practices: Protecting Your Data

SQL Server Security Best Practices: Protecting Your Data

Introduction

Database security is critical for protecting sensitive data from unauthorized access and breaches. This guide covers SQL Server authentication modes, encryption techniques (TDE, Always Encrypted), SQL injection prevention, auditing, row-level security, and comprehensive best practices for securing enterprise databases.

Authentication and Authorization

Authentication Modes

Windows Authentication (Recommended):

-- Create Windows login
CREATE LOGIN [CONTOSO\JohnDoe] FROM WINDOWS;

-- Grant access to database
USE [AdventureWorks];
CREATE USER [CONTOSO\JohnDoe] FOR LOGIN [CONTOSO\JohnDoe];

-- Add to database role
ALTER ROLE db_datareader ADD MEMBER [CONTOSO\JohnDoe];

SQL Server Authentication:

-- Create SQL login with strong password policy
CREATE LOGIN AppUser 
WITH PASSWORD = 'StrongP@ssw0rd123!',
     CHECK_POLICY = ON,
     CHECK_EXPIRATION = ON;

-- Create database user
USE [AdventureWorks];
CREATE USER AppUser FOR LOGIN AppUser;

-- Grant specific permissions
GRANT SELECT, INSERT, UPDATE ON dbo.Orders TO AppUser;

Principle of Least Privilege

-- Create custom database role
CREATE ROLE OrdersReader;

-- Grant minimal permissions
GRANT SELECT ON dbo.Orders TO OrdersReader;
GRANT SELECT ON dbo.OrderDetails TO OrdersReader;

-- Add user to role
ALTER ROLE OrdersReader ADD MEMBER AppUser;

-- Deny sensitive columns
DENY SELECT ON dbo.Customers(CreditCardNumber) TO OrdersReader;

Server-Level Permissions

-- View server-level permissions
SELECT 
    pr.name AS PrincipalName,
    pr.type_desc AS PrincipalType,
    pe.permission_name,
    pe.state_desc
FROM sys.server_principals pr
INNER JOIN sys.server_permissions pe ON pr.principal_id = pe.grantee_principal_id
WHERE pr.name = 'AppUser';

-- Grant server permissions
GRANT VIEW SERVER STATE TO AppUser;
GRANT VIEW ANY DEFINITION TO AppUser;

-- Revoke dangerous permissions
REVOKE CONTROL SERVER FROM AppUser;

Encryption

Transparent Data Encryption (TDE)

Encrypts entire database at rest:

-- Create master key in master database
USE master;
GO
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MasterKeyP@ssw0rd123!';
GO

-- Create certificate
CREATE CERTIFICATE TDECert 
WITH SUBJECT = 'TDE Certificate for AdventureWorks';
GO

-- Backup certificate (CRITICAL - store securely!)
BACKUP CERTIFICATE TDECert
TO FILE = 'C:\Backup\TDECert.cer'
WITH PRIVATE KEY (
    FILE = 'C:\Backup\TDECert.pvk',
    ENCRYPTION BY PASSWORD = 'CertP@ssw0rd123!'
);
GO

-- Create database encryption key
USE AdventureWorks;
GO
CREATE DATABASE ENCRYPTION KEY
WITH ALGORITHM = AES_256
ENCRYPTION BY SERVER CERTIFICATE TDECert;
GO

-- Enable TDE
ALTER DATABASE AdventureWorks
SET ENCRYPTION ON;
GO

-- Verify encryption status
SELECT 
    DB_NAME(database_id) AS DatabaseName,
    encryption_state,
    CASE encryption_state
        WHEN 0 THEN 'No encryption'
        WHEN 1 THEN 'Unencrypted'
        WHEN 2 THEN 'Encryption in progress'
        WHEN 3 THEN 'Encrypted'
        WHEN 4 THEN 'Key change in progress'
        WHEN 5 THEN 'Decryption in progress'
    END AS EncryptionStatus,
    percent_complete,
    encryptor_type
FROM sys.dm_database_encryption_keys;

Always Encrypted

Encrypts specific columns, data remains encrypted in memory:

-- Enable Always Encrypted in SSMS
-- Tools → Options → Query Execution → SQL Server → Advanced
-- Set "Enable Parameterization for Always Encrypted" = True

-- Create column master key (CMK)
CREATE COLUMN MASTER KEY CMK_Auto1
WITH (
    KEY_STORE_PROVIDER_NAME = 'MSSQL_CERTIFICATE_STORE',
    KEY_PATH = 'CurrentUser/My/A1B2C3D4E5F6789012345678901234567890ABCD'
);

-- Create column encryption key (CEK)
CREATE COLUMN ENCRYPTION KEY CEK_Auto1
WITH VALUES (
    COLUMN_MASTER_KEY = CMK_Auto1,
    ALGORITHM = 'RSA_OAEP',
    ENCRYPTED_VALUE = 0x016E000001630075007200720065006E00740075007300650072002F006D0079002F006100310062003200630033006400340065003500660036003700380039003000310032003300340035003600370038003900300031003200330034003500360037003800390030006100620063006400
);

-- Create table with encrypted columns
CREATE TABLE dbo.Customers (
    CustomerID INT PRIMARY KEY IDENTITY,
    FirstName NVARCHAR(50),
    LastName NVARCHAR(50),
    Email NVARCHAR(100),
    SSN NVARCHAR(11) COLLATE Latin1_General_BIN2 
        ENCRYPTED WITH (
            COLUMN_ENCRYPTION_KEY = CEK_Auto1,
            ENCRYPTION_TYPE = DETERMINISTIC,
            ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256'
        ),
    CreditCardNumber NVARCHAR(19) COLLATE Latin1_General_BIN2
        ENCRYPTED WITH (
            COLUMN_ENCRYPTION_KEY = CEK_Auto1,
            ENCRYPTION_TYPE = RANDOMIZED,
            ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256'
        )
);

-- Insert data (requires Always Encrypted-enabled connection)
INSERT INTO dbo.Customers (FirstName, LastName, Email, SSN, CreditCardNumber)
VALUES ('John', 'Doe', 'john.doe@contoso.com', '123-45-6789', '4111-1111-1111-1111');

Deterministic vs. Randomized:

  • Deterministic: Same plaintext = same ciphertext (supports equality, grouping)
  • Randomized: Same plaintext = different ciphertext (more secure, no operations)

Connection String Encryption

// .NET connection string with Always Encrypted
string connectionString = "Server=myserver;Database=AdventureWorks;Integrated Security=true;Column Encryption Setting=Enabled;";

using (SqlConnection connection = new SqlConnection(connectionString))
{
    connection.Open();
    
    // Queries automatically encrypt/decrypt
    SqlCommand command = new SqlCommand(
        "SELECT FirstName, LastName, SSN FROM dbo.Customers WHERE SSN = @SSN",
        connection
    );
    command.Parameters.AddWithValue("@SSN", "123-45-6789");
    
    SqlDataReader reader = command.ExecuteReader();
    while (reader.Read())
    {
        Console.WriteLine($"{reader["FirstName"]} {reader["LastName"]}: {reader["SSN"]}");
    }
}

SQL Injection Prevention

Parameterized Queries

-- ❌ VULNERABLE - Dynamic SQL with concatenation
DECLARE @Username NVARCHAR(50) = 'admin'' OR ''1''=''1';
DECLARE @SQL NVARCHAR(MAX) = 'SELECT * FROM Users WHERE Username = ''' + @Username + '''';
EXEC sp_executesql @SQL;  -- Returns all users!

-- ✅ SECURE - Parameterized query
DECLARE @Username NVARCHAR(50) = 'admin'' OR ''1''=''1';
DECLARE @SQL NVARCHAR(MAX) = 'SELECT * FROM Users WHERE Username = @User';
EXEC sp_executesql @SQL, N'@User NVARCHAR(50)', @User = @Username;  -- Treats as literal

Input Validation

CREATE PROCEDURE GetCustomerOrders
    @CustomerID INT
AS
BEGIN
    -- Validate input
    IF @CustomerID IS NULL OR @CustomerID <= 0
    BEGIN
        RAISERROR('Invalid CustomerID', 16, 1);
        RETURN;
    END
    
    -- Safe parameterized query
    SELECT 
        OrderID,
        OrderDate,
        TotalAmount
    FROM dbo.Orders
    WHERE CustomerID = @CustomerID;
END;

Whitelist Validation

CREATE PROCEDURE SearchProducts
    @SortColumn NVARCHAR(50) = 'ProductName',
    @SortDirection NVARCHAR(4) = 'ASC'
AS
BEGIN
    -- Whitelist allowed columns
    IF @SortColumn NOT IN ('ProductName', 'Price', 'CategoryID')
    BEGIN
        RAISERROR('Invalid sort column', 16, 1);
        RETURN;
    END
    
    -- Whitelist sort direction
    IF @SortDirection NOT IN ('ASC', 'DESC')
    BEGIN
        SET @SortDirection = 'ASC';
    END
    
    -- Build safe dynamic SQL
    DECLARE @SQL NVARCHAR(MAX) = 
        'SELECT ProductID, ProductName, Price, CategoryID 
         FROM dbo.Products 
         ORDER BY ' + QUOTENAME(@SortColumn) + ' ' + @SortDirection;
    
    EXEC sp_executesql @SQL;
END;

Auditing

SQL Server Audit

-- Create server audit
USE master;
GO
CREATE SERVER AUDIT SecurityAudit
TO FILE (
    FILEPATH = 'C:\Audit\',
    MAXSIZE = 100 MB,
    MAX_ROLLOVER_FILES = 10,
    RESERVE_DISK_SPACE = OFF
)
WITH (
    QUEUE_DELAY = 1000,
    ON_FAILURE = CONTINUE
);
GO

-- Enable audit
ALTER SERVER AUDIT SecurityAudit
WITH (STATE = ON);
GO

-- Create server audit specification (server-level events)
CREATE SERVER AUDIT SPECIFICATION LoginAudit
FOR SERVER AUDIT SecurityAudit
ADD (FAILED_LOGIN_GROUP),
ADD (SUCCESSFUL_LOGIN_GROUP),
ADD (AUDIT_CHANGE_GROUP)
WITH (STATE = ON);
GO

-- Create database audit specification (database-level events)
USE AdventureWorks;
GO
CREATE DATABASE AUDIT SPECIFICATION SensitiveDataAccess
FOR SERVER AUDIT SecurityAudit
ADD (SELECT, UPDATE ON dbo.Customers BY public),
ADD (DELETE ON dbo.Orders BY public)
WITH (STATE = ON);
GO

-- Query audit log
SELECT 
    event_time,
    action_id,
    succeeded,
    server_principal_name,
    database_name,
    schema_name,
    object_name,
    statement
FROM sys.fn_get_audit_file('C:\Audit\*.sqlaudit', DEFAULT, DEFAULT)
WHERE event_time >= DATEADD(DAY, -7, GETDATE())
ORDER BY event_time DESC;

Change Data Capture (CDC)

-- Enable CDC on database
USE AdventureWorks;
GO
EXEC sys.sp_cdc_enable_db;
GO

-- Enable CDC on table
EXEC sys.sp_cdc_enable_table
    @source_schema = N'dbo',
    @source_name = N'Customers',
    @role_name = NULL,
    @supports_net_changes = 1;
GO

-- Query changes
DECLARE @begin_lsn BINARY(10), @end_lsn BINARY(10);

SET @begin_lsn = sys.fn_cdc_map_time_to_lsn('smallest greater than or equal', DATEADD(HOUR, -1, GETDATE()));
SET @end_lsn = sys.fn_cdc_get_max_lsn();

SELECT 
    __$operation AS Operation,  -- 1=Delete, 2=Insert, 3=Before Update, 4=After Update
    __$start_lsn,
    CustomerID,
    FirstName,
    LastName,
    Email
FROM cdc.fn_cdc_get_all_changes_dbo_Customers(@begin_lsn, @end_lsn, 'all')
ORDER BY __$start_lsn;

Row-Level Security (RLS)

-- Create security policy for multi-tenant application
CREATE SCHEMA Security;
GO

-- Create inline table-valued function
CREATE FUNCTION Security.fn_CustomerSecurityPredicate(@CustomerID INT)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS result
WHERE @CustomerID = CAST(SESSION_CONTEXT(N'CustomerID') AS INT)
    OR IS_MEMBER('db_owner') = 1;
GO

-- Create security policy
CREATE SECURITY POLICY Security.CustomerSecurityPolicy
ADD FILTER PREDICATE Security.fn_CustomerSecurityPredicate(CustomerID) ON dbo.Orders,
ADD BLOCK PREDICATE Security.fn_CustomerSecurityPredicate(CustomerID) ON dbo.Orders AFTER INSERT
WITH (STATE = ON);
GO

-- Set session context (typically in application)
EXEC sp_set_session_context @key = N'CustomerID', @value = 1001;

-- User only sees their own orders
SELECT * FROM dbo.Orders;  -- Filtered by CustomerID = 1001

-- Bypass RLS (for administrators)
SELECT * FROM dbo.Orders WITH (SECURITY_BYPASS);

Dynamic Data Masking

-- Create table with masked columns
CREATE TABLE dbo.Employees (
    EmployeeID INT PRIMARY KEY IDENTITY,
    FirstName NVARCHAR(50),
    LastName NVARCHAR(50),
    Email NVARCHAR(100) MASKED WITH (FUNCTION = 'email()'),
    SSN NVARCHAR(11) MASKED WITH (FUNCTION = 'partial(0,"XXX-XX-",4)'),
    Salary DECIMAL(10,2) MASKED WITH (FUNCTION = 'default()')
);

-- Insert data
INSERT INTO dbo.Employees (FirstName, LastName, Email, SSN, Salary)
VALUES ('John', 'Doe', 'john.doe@contoso.com', '123-45-6789', 75000.00);

-- Regular user sees masked data
-- Email: jXXX@XXXX.com
-- SSN: XXX-XX-6789
-- Salary: 0.00

-- Grant UNMASK permission
GRANT UNMASK TO AppUser;

-- Now sees actual data
SELECT * FROM dbo.Employees;

Security Best Practices

Disable sa Account

-- Disable sa account
ALTER LOGIN sa DISABLE;

-- Create dedicated admin account
CREATE LOGIN [CONTOSO\SQLAdmin] FROM WINDOWS;
ALTER SERVER ROLE sysadmin ADD MEMBER [CONTOSO\SQLAdmin];

Password Policies

-- Enforce strong password policy
ALTER LOGIN AppUser 
WITH PASSWORD = 'NewStrongP@ssw0rd123!',
     CHECK_POLICY = ON,
     CHECK_EXPIRATION = ON;

-- Review accounts with weak policies
SELECT 
    name,
    is_policy_checked,
    is_expiration_checked,
    create_date,
    modify_date
FROM sys.sql_logins
WHERE is_policy_checked = 0 OR is_expiration_checked = 0;

Surface Area Reduction

-- Disable unnecessary features
EXEC sp_configure 'xp_cmdshell', 0;
RECONFIGURE;

EXEC sp_configure 'Ole Automation Procedures', 0;
RECONFIGURE;

-- Disable unused protocols (via SQL Server Configuration Manager)
-- Typically only enable TCP/IP, disable Named Pipes, Shared Memory (except local)

Regular Security Audits

-- Review server permissions
SELECT 
    pr.name AS LoginName,
    pe.permission_name,
    pe.state_desc
FROM sys.server_principals pr
INNER JOIN sys.server_permissions pe ON pr.principal_id = pe.grantee_principal_id
WHERE pr.type IN ('S', 'U')  -- SQL and Windows logins
ORDER BY pr.name;

-- Review database permissions
USE AdventureWorks;
GO
SELECT 
    USER_NAME(grantee_principal_id) AS UserName,
    OBJECT_NAME(major_id) AS ObjectName,
    permission_name,
    state_desc
FROM sys.database_permissions
WHERE major_id > 0
ORDER BY UserName;

-- Identify orphaned users
SELECT 
    dp.name AS DatabaseUser,
    dp.type_desc,
    dp.create_date
FROM sys.database_principals dp
LEFT JOIN sys.server_principals sp ON dp.sid = sp.sid
WHERE dp.type IN ('S', 'U')
    AND sp.sid IS NULL
    AND dp.name NOT IN ('dbo', 'guest', 'INFORMATION_SCHEMA', 'sys');

Connection Encryption

-- Force encrypted connections (Server Configuration)
-- SQL Server Configuration Manager → SQL Server Network Configuration
-- → Protocols for [Instance] → Properties → Force Encryption = Yes

-- Verify encrypted connections
SELECT 
    session_id,
    login_name,
    host_name,
    program_name,
    encrypt_option
FROM sys.dm_exec_connections
WHERE encrypt_option = 'TRUE';

Compliance and Regulations

GDPR Compliance

-- Right to be forgotten
CREATE PROCEDURE dbo.DeleteCustomerData
    @CustomerID INT
AS
BEGIN
    SET NOCOUNT ON;
    
    BEGIN TRY
        BEGIN TRANSACTION;
        
        -- Delete personal data
        DELETE FROM dbo.Orders WHERE CustomerID = @CustomerID;
        DELETE FROM dbo.Addresses WHERE CustomerID = @CustomerID;
        DELETE FROM dbo.Customers WHERE CustomerID = @CustomerID;
        
        -- Audit deletion
        INSERT INTO dbo.AuditLog (Action, CustomerID, Timestamp)
        VALUES ('GDPR_DELETE', @CustomerID, GETUTCDATE());
        
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        ROLLBACK TRANSACTION;
        THROW;
    END CATCH;
END;

Data Retention Policies

-- Archive old data
CREATE PROCEDURE dbo.ArchiveOldOrders
AS
BEGIN
    -- Move orders older than 7 years to archive
    INSERT INTO dbo.OrdersArchive
    SELECT * FROM dbo.Orders
    WHERE OrderDate < DATEADD(YEAR, -7, GETDATE());
    
    DELETE FROM dbo.Orders
    WHERE OrderDate < DATEADD(YEAR, -7, GETDATE());
END;

Key Takeaways

  • Use Windows Authentication over SQL Authentication
  • Implement principle of least privilege
  • Enable TDE for data at rest encryption
  • Use Always Encrypted for sensitive columns
  • Prevent SQL injection with parameterized queries
  • Enable SQL Server Audit for compliance
  • Implement row-level security for multi-tenant scenarios
  • Regularly review and audit permissions
  • Enforce strong password policies
  • Keep SQL Server patched and updated

Next Steps

  • Audit current security configuration
  • Implement TDE for production databases
  • Enable SQL Server Audit
  • Review and remediate excessive permissions
  • Create security baseline and monitoring

Additional Resources


Secure by design. Protected by practice.