The Three Permission States
In SQL Server, a permission has three possible states for any principal (user, role, group):
| State | Meaning | Effect |
|---|---|---|
| GRANT | Permission explicitly allowed | User CAN perform the action |
| DENY | Permission explicitly forbidden | User CANNOT perform the action (overrides GRANT) |
| No Entry | Neither GRANT nor DENY | User CANNOT perform the action (implicit deny) |
Key principle: DENY always wins. Even if a role grants SELECT, a DENY on the user overrides it.
Permission Hierarchy
Permissions flow down from server to database to schema to object. A user's effective permissions at each level are inherited from above.
Server (CONTROL SERVER, IMPERSONATE LOGIN, etc.)
└─ Database (CREATE TABLE, CREATE PROCEDURE, etc.)
└─ Schema (ALTER, EXECUTE, etc.)
└─ Object (SELECT, INSERT, UPDATE, DELETE, EXECUTE, etc.)
Example: Permission Inheritance
-- User "app_user" is a member of role "db_datareader"
-- db_datareader is granted SELECT on the entire database
-- The user inherits:
-- SELECT on all tables
-- SELECT on all views
-- Recursively through schema and object
-- But if we DENY SELECT on a specific table:
-- DENY overrides the inherited GRANT
-- User CANNOT SELECT from that specific table
-- But CAN SELECT from all other tables (GRANT still applies)
GRANT: Explicit Allow
Basic Syntax
GRANT ON TO ;
Examples
-- Grant SELECT on a table
GRANT SELECT ON dbo.Customers TO app_user;
-- Grant SELECT on all tables in a schema
GRANT SELECT ON SCHEMA::dbo TO app_user;
-- Grant EXECUTE on a stored procedure
GRANT EXECUTE ON dbo.sp_GetCustomers TO app_user;
-- Grant multiple permissions
GRANT SELECT, INSERT, UPDATE ON dbo.Customers TO app_user;
-- Grant with GRANT OPTION (user can grant to others)
GRANT SELECT ON dbo.Customers TO app_user WITH GRANT OPTION;
Common Permissions
| Permission | Level | Use Case |
|---|---|---|
SELECT |
Table/View | Read data |
INSERT |
Table/View | Add rows |
UPDATE |
Table/View | Modify rows |
DELETE |
Table/View | Remove rows |
EXECUTE |
Procedure/Function | Run code |
CREATE TABLE |
Database/Schema | Create schema objects |
ALTER |
Schema/Object | Modify schema objects |
CONTROL |
Any level | Full ownership (implies all permissions) |
DENY: Explicit Forbid (The Override)
DENY ON TO ;
Examples
-- Forbid DELETE on Customers table (even if user has role grant)
DENY DELETE ON dbo.Customers TO app_user;
-- Forbid UPDATE on sensitive columns
DENY UPDATE ON dbo.Employees (Salary) TO junior_dev;
-- Forbid CREATE TABLE in database
DENY CREATE TABLE TO intern_user;
DENY Is Absolute
-- Setup:
-- app_user is member of role "readers"
-- readers role is GRANTED SELECT on all tables
-- app_user directly receives GRANT SELECT on dbo.Customers
-- Then we execute:
DENY SELECT ON dbo.Customers TO app_user;
-- Result:
-- app_user CANNOT SELECT from dbo.Customers
-- DENY overrides both the inherited GRANT (from readers role)
-- AND the direct GRANT on that specific user
-- Why?
-- DENY is a security boundary. It means "never, regardless of other permissions"
REVOKE: Remove Explicit Permission
REVOKE ON FROM ;
Examples
-- Remove explicit GRANT
REVOKE SELECT ON dbo.Customers FROM app_user;
-- Remove explicit DENY
REVOKE DELETE ON dbo.Customers FROM app_user;
-- After REVOKE, permission status:
-- If the user is in a role with the permission, they still have it
-- If not, they lose access (implicit deny)
What REVOKE Actually Does
-- Before REVOKE:
SELECT app_user permission on dbo.Customers: GRANT
-- After REVOKE SELECT ON dbo.Customers FROM app_user:
SELECT app_user permission on dbo.Customers: (no entry)
-- BUT:
-- If app_user is in a role with SELECT GRANTED:
SELECT app_user permission on dbo.Customers (via role): GRANT ← Still inherited!
-- To remove that, REVOKE the role or DENY at user level
Real-World Scenario: The Permission Mess
Initial Setup
-- Create a developer user
CREATE LOGIN dev_john WITH PASSWORD = 'TempPassword123!';
CREATE USER dev_john FOR LOGIN dev_john;
-- Add to db_datareader role (standard read-only access)
ALTER ROLE db_datareader ADD MEMBER dev_john;
-- Dev needs to INSERT into a staging table, so grant extra permission
GRANT INSERT ON dbo.Staging TO dev_john;
-- Dev needs to run a specific procedure
GRANT EXECUTE ON dbo.usp_ImportData TO dev_john;
Current Permissions
dev_john can:
✓ SELECT any table (via db_datareader role)
✓ SELECT any view (via db_datareader role)
✓ INSERT into dbo.Staging (direct GRANT)
✓ EXECUTE dbo.usp_ImportData (direct GRANT)
✗ UPDATE any table (implicit deny)
✗ DELETE any table (implicit deny)
✗ CREATE objects (implicit deny)
Two Months Later: Audit Discovers Unauthorized Access
An auditor finds that dev_john has UPDATE permission on dbo.Staging (a sensitive table with financial data). But nobody granted it explicitly. Where did it come from?
Investigation:
-- Check permissions on the table
SELECT
permission_name,
state_desc,
principal_name
FROM sys.database_permissions dp
INNER JOIN sys.database_principals pr
ON dp.grantee_principal_id = pr.principal_id
WHERE dp.major_id = OBJECT_ID('dbo.Staging')
AND principal_name = 'dev_john';
-- Result: Nothing! No direct GRANT or DENY for dev_john on Staging
-- Check roles
SELECT name FROM sys.database_role_members
INNER JOIN sys.database_principals pr ON sys.database_role_members.role_principal_id = pr.principal_id
WHERE member_principal_id = (SELECT principal_id FROM sys.database_principals WHERE name = 'dev_john');
-- Aha! dev_john is in a role. Check role permissions:
SELECT
permission_name,
state_desc
FROM sys.database_permissions
WHERE grantee_principal_id = (SELECT principal_id FROM sys.database_principals WHERE name = 'db_datawriter')
AND major_id = OBJECT_ID('dbo.Staging');
-- Result: GRANT UPDATE (and INSERT, DELETE) via db_datawriter role
-- Someone added dev_john to db_datawriter by mistake!
Root cause: Someone added dev_john to db_datawriter (which has INSERT, UPDATE, DELETE on all tables). The developer had elevated permissions they didn't need.
Fix:
-- Remove from db_datawriter
ALTER ROLE db_datawriter DROP MEMBER dev_john;
-- Keep only db_datareader
-- Keep the specific GRANT for INSERT on Staging
-- Now dev_john has: SELECT (all) + INSERT (Staging) + EXECUTE (usp_ImportData)
Principle of Least Privilege (PoLP)
Users should have only the minimum permissions needed to do their job. Over-granting leads to:
- Accidental data modification or deletion
- Compliance violations (SOX, HIPAA, PCI-DSS require auditable access)
- Insider threat risk (intentional misuse)
- Confusion about who can do what
Better Permissions Model
-- Create custom roles for business functions
CREATE ROLE sales_analysts;
CREATE ROLE finance_auditors;
CREATE ROLE it_admins;
-- Grant minimal permissions
GRANT SELECT ON SCHEMA::dbo TO sales_analysts;
GRANT SELECT ON dbo.Customers TO sales_analysts;
GRANT SELECT ON dbo.Orders TO sales_analysts;
-- (No INSERT, UPDATE, DELETE)
GRANT SELECT ON SCHEMA::dbo TO finance_auditors;
GRANT SELECT ON dbo.FinancialTransactions TO finance_auditors;
-- (Read-only access to specific financial tables)
-- Add users to roles (instead of direct grants)
ALTER ROLE sales_analysts ADD MEMBER david_sales;
ALTER ROLE finance_auditors ADD MEMBER sarah_finance;
-- If a user needs extra access, grant at the user level (explicit)
-- This makes audit trails clearer:
GRANT EXECUTE ON dbo.usp_ExportReport TO david_sales;
Common Permission Pitfalls
Pitfall 1: DENY Without Understanding Precedence
-- Mistake: Trying to prevent a specific action
ALTER ROLE db_owner DROP MEMBER junior_dev; -- Remove from owner role
GRANT SELECT ON dbo.Customers TO junior_dev; -- Grant only SELECT
-- But then someone adds junior_dev to db_securityadmin role
-- Which inherits all permissions from the database
-- Result: junior_dev now has more power than db_owner
-- and GRANT SELECT is irrelevant
-- Better: Use DENY to be explicit
ALTER ROLE db_securityadmin DROP MEMBER junior_dev;
GRANT SELECT ON SCHEMA::dbo TO junior_dev;
Pitfall 2: Forgetting Inherited Permissions
-- User is in a role with broad permissions
-- You try to restrict by granting more specific permissions
-- But the role's permissions still apply!
GRANT SELECT ON dbo.Customers TO user_in_datareader_role;
-- This doesn't remove their inherited datareader permissions
-- It just adds a redundant explicit grant
-- To truly restrict, use DENY:
DENY UPDATE ON dbo.Customers TO user_in_datareader_role;
Pitfall 3: Using sysadmin for Everything
-- Anti-pattern: Make application account sysadmin
-- "It's easier, and we'll be more careful"
-- Result: One SQL injection = full server compromise
-- Better: Use service accounts with minimal necessary permissions
-- If the app is an ETL process, it needs:
GRANT CREATE TABLE ON DATABASE::staging TO etl_service;
GRANT SELECT ON DATABASE::production TO etl_service;
GRANT INSERT, DELETE ON DATABASE::staging TO etl_service;
-- NOT sysadmin. Ever.
Auditing Permissions
-- Check who has what permissions
SELECT
pr.name AS principal_name,
pr.type_desc,
perm.permission_name,
perm.state_desc,
obj.name AS object_name
FROM sys.database_permissions perm
INNER JOIN sys.database_principals pr
ON perm.grantee_principal_id = pr.principal_id
INNER JOIN sys.objects obj
ON perm.major_id = obj.object_id
WHERE pr.name NOT IN ('dbo', 'guest') -- Exclude system principals
ORDER BY pr.name, perm.permission_name;
-- Check role membership
SELECT
rm.member_principal_id,
dp.name AS member_name,
rm.role_principal_id,
rp.name AS role_name
FROM sys.database_role_members rm
INNER JOIN sys.database_principals dp
ON rm.member_principal_id = dp.principal_id
INNER JOIN sys.database_principals rp
ON rm.role_principal_id = rp.principal_id
ORDER BY rp.name, dp.name;
Integration with Control Matrix
Our Control Matrix provides a comprehensive framework for implementing least-privilege access control across your SQL Server infrastructure. This article's principles directly align with the control objectives defined there.
The Bottom Line
SQL Server permissions are not complicated; they just follow strict, unintuitive rules:
- GRANT = "user can do this"
- DENY = "user absolutely cannot, no matter what"
- REVOKE = "remove the explicit GRANT or DENY" (leaves implicit deny if no other grant)
- DENY always wins (over-rides inherited GRANT)
- No entry = implicit deny (most restrictive default)
Follow principle of least privilege. Use roles for consistency. Audit regularly. And never, ever make an application account sysadmin.