SQL Server Security — Logins, Users, Roles and Row-Level Security

SQL Server’s security model operates at two levels: the server level (logins — who can connect to the SQL Server instance) and the database level (users — what an authenticated login can do within a specific database). The principle of least privilege — granting only the minimum permissions necessary — is the foundation of secure database access. The BlogApp API should connect with a login that has only db_datareader, db_datawriter, and db_ddladmin (for migrations) — never sa or db_owner.

Logins, Users and Permissions

-- ── Server-level: create a login ──────────────────────────────────────────
USE master;
GO

-- SQL Server Authentication login (use Windows Auth in on-premises where possible)
CREATE LOGIN blogapp_api WITH PASSWORD = N'Str0ng!Pass#BlogApp2025',
    CHECK_EXPIRATION = ON,    -- enforce password expiry policy
    CHECK_POLICY     = ON;    -- enforce Windows password complexity policy
GO

-- ── Database-level: create a user for the login ───────────────────────────
USE BlogApp;
GO
CREATE USER blogapp_api FOR LOGIN blogapp_api;
GO

-- Grant minimum required permissions:
ALTER ROLE db_datareader ADD MEMBER blogapp_api;   -- SELECT on all tables
ALTER ROLE db_datawriter ADD MEMBER blogapp_api;   -- INSERT/UPDATE/DELETE
ALTER ROLE db_ddladmin   ADD MEMBER blogapp_api;   -- CREATE/ALTER/DROP (for migrations)
-- Remove db_ddladmin in production if migrations run via a separate deployment account

-- Grant EXECUTE on specific stored procedures:
GRANT EXECUTE ON dbo.usp_PublishPost   TO blogapp_api;
GRANT EXECUTE ON dbo.usp_GetPublishedPosts TO blogapp_api;
GO

-- ── Row-Level Security — multi-tenant filtering at database level ──────────
-- Scenario: multiple blog sites share one database; each site sees only its data

-- 1. Add TenantId to Posts (if multi-tenant):
-- ALTER TABLE dbo.Posts ADD TenantId INT NOT NULL DEFAULT 1;

-- 2. Create the predicate function
CREATE OR ALTER FUNCTION dbo.fn_PostTenantFilter(@TenantId INT)
RETURNS TABLE WITH SCHEMABINDING
AS RETURN
    SELECT 1 AS fn_result
    WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS INT);
        -- SESSION_CONTEXT is set by the app on each connection
GO

-- 3. Create the security policy
CREATE SECURITY POLICY PostTenantPolicy
    ADD FILTER PREDICATE dbo.fn_PostTenantFilter(TenantId) ON dbo.Posts
    WITH (STATE = ON);
GO

-- 4. Application sets context on each request:
-- EXEC sp_set_session_context N'TenantId', @TenantId;
-- Now all queries on Posts automatically filter to the tenant's rows

-- ── Dynamic Data Masking — hide sensitive data from low-privilege accounts ─
ALTER TABLE dbo.Users
    ALTER COLUMN Email ADD MASKED WITH (FUNCTION = 'email()');
-- Low-privilege users see: aXXX@XXXX.com (first char + masked domain)
-- db_owner and users with UNMASK permission see the real value

-- ── Always Encrypted — client-side column encryption ─────────────────────
-- Sensitive columns encrypted before leaving the client — SQL Server never sees plaintext
-- Requires Always Encrypted driver configuration in the connection string
-- Best for: SSN, credit card numbers, health data
Note: Row-Level Security (RLS) enforces access policies at the SQL Server engine level — even if the application code has a bug that omits a tenant filter, SQL Server’s security policy automatically filters the rows. This is defence-in-depth: the application filters by tenant, and SQL Server enforces the filter independently. RLS predicates are transparent to queries — a SELECT * FROM Posts automatically returns only the current session’s tenant rows. The performance overhead is minimal for simple predicate functions like the one shown.
Tip: Use Managed Identity (in Azure) instead of SQL Server Authentication for the BlogApp API. With Managed Identity, the application authenticates to Azure SQL without a password — no credentials to rotate, store, or potentially leak. The connection string uses Authentication=Active Directory Default instead of a username and password. EF Core supports Managed Identity via the Microsoft.Data.SqlClient provider which handles token acquisition automatically. This eliminates the largest category of database credential exposure incidents.
Warning: The sa account (System Administrator) has unrestricted access to the entire SQL Server instance. Always disable it in production: ALTER LOGIN sa DISABLE. Also rename or disable other default accounts (BUILTIN\Administrators in Windows Auth environments). Use the principle of least privilege — the application login should have only the permissions it needs, not blanket db_owner access. A SQL injection vulnerability with a db_owner connection can modify any table; with a db_datareader + db_datawriter connection, the blast radius is limited to DML operations.

Permission Reference

Role Permissions Appropriate For
db_datareader SELECT all tables Reporting accounts
db_datawriter INSERT/UPDATE/DELETE all tables Application accounts
db_ddladmin CREATE/ALTER/DROP objects Migration accounts
db_owner All permissions + DROP DATABASE DBA only — not applications
sysadmin Unrestricted server-wide DBA only — not applications

Common Mistakes

Mistake 1 — Application connecting as sa or db_owner (excessive privileges)

❌ Wrong — connection string with User Id=sa; SQL injection gives attacker unrestricted server access.

✅ Correct — dedicated application login with db_datareader + db_datawriter only; limit blast radius.

Mistake 2 — Storing SQL Server password in connection string in source control

❌ Wrong — password in appsettings.json committed to Git; credentials exposed to all developers.

✅ Correct — use environment variables, Azure Key Vault, or Managed Identity; never commit credentials.

🧠 Test Yourself

The BlogApp API connects as a login with only db_datareader and db_datawriter. A SQL injection attack executes DROP TABLE Posts. What happens?