This chapter covers Row-Level Security (RLS) in Azure SQL Database and Azure SQL Managed Instance, a feature that restricts data access at the row level based on user identity or context. For the DP-900 exam, understanding RLS is critical as it tests your knowledge of implementing fine-grained security within relational databases, a key aspect of data security and compliance. Approximately 5–10% of exam questions may touch on this area, often in the context of multi-tenant applications or data masking alternatives. By the end of this chapter, you will understand how RLS works, how to configure it, and how it differs from other security mechanisms.
Jump to a section
Imagine a large office building where employees have security badges. The building's main entrance (the database) is open to everyone with a badge, but each floor (table) and room (row) has additional restrictions. A security guard at the entrance (the database server) checks your badge, but the badge itself contains a list of floors and rooms you're allowed to enter. When you swipe your badge to open a door on the 3rd floor, the door's reader checks your badge's stored permissions and only unlocks if you have access. This is exactly how Row-Level Security (RLS) works: the security policy is attached to the table itself, not the entrance. The badge (user's login) carries a claim (like department or role), and the door (the table) checks that claim against a predefined rule to decide if you can see that specific row. If your badge says 'HR', you see only HR rows; if 'IT', only IT rows. The guard doesn't need to know every employee's permissions—the policy is enforced at the row level, automatically filtering what each user sees, just like the door reader filtering access per room.
What is Row-Level Security (RLS)?
Row-Level Security is a feature in Azure SQL Database and Azure SQL Managed Instance that enables you to control access to rows in a database table based on the characteristics of the user executing a query. For example, a sales manager should only see sales data for their own region, while a global manager sees all rows. RLS enforces these restrictions transparently: the user does not need to modify their queries; the database engine automatically adds a filter predicate to every query against a table protected by an RLS policy.
Why RLS Exists
In multi-tenant applications, where multiple customers (tenants) share the same database tables, it is critical to ensure that tenant A cannot see tenant B's data. Traditional approaches like creating separate databases per tenant or adding WHERE clauses in every query are cumbersome and error-prone. RLS provides a centralized, declarative way to enforce row-level isolation at the database level, reducing the risk of data leakage and simplifying application code.
How RLS Works Internally
RLS uses a security policy that defines a filter predicate. A filter predicate is a function (an inline table-valued function) that returns 1 (true) for rows the user should see and 0 (false) for rows to hide. This function typically uses built-in functions like USER_NAME(), SUSER_SNAME(), or SESSION_CONTEXT() to determine the user's identity or context. When a query is executed against a table with an RLS policy, the SQL Server query optimizer automatically rewrites the query to include the predicate as a filter. This rewriting happens at compile time, so the predicate is applied to all operations—SELECT, INSERT, UPDATE, DELETE—unless a block predicate is defined separately.
Key Components
Security Policy: An object that binds a filter predicate to a table. Created using CREATE SECURITY POLICY.
Filter Predicate: A function that determines which rows are visible for read operations (SELECT, UPDATE, DELETE).
Block Predicate: A function that controls write operations (INSERT, UPDATE, DELETE) by blocking rows that violate the policy. Block predicates are evaluated after the filter predicate.
Inline Table-Valued Function (TVF): The function that contains the logic for filtering. It must be schema-bound and cannot return results; instead, it is used as a predicate.
SESSION_CONTEXT(): A function that allows applications to set key-value pairs in the session context, which can be used by the predicate to determine access. This is more secure than relying on USER_NAME() because the context can be set by the application after authentication.
Creating an RLS Policy
Step 1: Create a schema to hold the predicate functions (optional but recommended).
CREATE SCHEMA rls;
GOStep 2: Create an inline table-valued function that defines the filter logic.
CREATE FUNCTION rls.fn_salesFilter(@SalesRepID AS INT)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS result
WHERE @SalesRepID = CAST(SESSION_CONTEXT(N'SalesRepID') AS INT)
OR CAST(SESSION_CONTEXT(N'Role') AS NVARCHAR(50)) = 'Manager';
GOStep 3: Create the security policy that binds the function to the table.
CREATE SECURITY POLICY rls.salesPolicy
ADD FILTER PREDICATE rls.fn_salesFilter(SalesRepID)
ON dbo.Sales
WITH (STATE = ON);
GOStep 4: Set the session context in the application before executing queries.
EXEC sp_set_session_context @key = N'SalesRepID', @value = 123;
EXEC sp_set_session_context @key = N'Role', @value = 'SalesRep';Verification and Management
To verify that RLS is active, query the system catalog views:
SELECT * FROM sys.security_policies;
SELECT * FROM sys.security_predicates;To disable or drop a policy:
ALTER SECURITY POLICY rls.salesPolicy WITH (STATE = OFF);
DROP SECURITY POLICY rls.salesPolicy;Interaction with Other Features
Indexing: RLS predicates can leverage indexes on the columns used in the predicate function. For best performance, ensure indexes exist on filtered columns.
Views: RLS is applied on the base table, even when accessed through a view. You cannot bypass RLS by creating a view.
Stored Procedures: If a stored procedure executes under the caller's context, RLS applies. If it uses EXECUTE AS to impersonate a different user, RLS uses that user's context.
Change Data Capture (CDC): RLS does not apply to CDC; the CDC capture instance can see all rows. This is a security consideration.
Cross-database queries: RLS is enforced only within the database where the policy exists. Cross-database queries may bypass RLS if not carefully designed.
Performance Considerations
RLS adds overhead because every query against a protected table must evaluate the predicate. However, the predicate is pushed down into the query plan, and indexes can mitigate performance impact. In practice, the overhead is minimal for simple predicates. Complex predicates involving joins or subqueries may degrade performance.
Limitations
RLS cannot be applied to system tables or temporary tables.
The predicate function must be schema-bound and cannot reference tables or views.
SESSION_CONTEXT() values are set by the application and are not authenticated by the database; therefore, they must be set securely (e.g., after authentication in the application layer).
RLS does not encrypt data; it only filters rows. Data at rest encryption is handled by Transparent Data Encryption (TDE).
Identify the security requirement
Begin by analyzing the data access patterns: which users or roles should see which rows? For example, in a multi-tenant application, each tenant should see only their own orders. Document the filtering logic based on user identity, role, or session context. This step determines the columns to use in the predicate (e.g., TenantID, SalesRepID, Region).
Create a schema for predicates
Optionally, create a dedicated schema to organize predicate functions. For example: CREATE SCHEMA rls;. This separates security logic from application schema and simplifies management. It is a best practice but not required.
Create the inline TVF predicate
Write an inline table-valued function that returns 1 for rows the user should see. The function must be schema-bound. Use SESSION_CONTEXT() or USER_NAME() to get the current user's context. Example: CREATE FUNCTION rls.fn_filter(@TenantID INT) RETURNS TABLE WITH SCHEMABINDING AS RETURN SELECT 1 AS result WHERE @TenantID = CAST(SESSION_CONTEXT(N'TenantID') AS INT);. The function cannot contain SELECT * or reference other tables.
Create the security policy
Use CREATE SECURITY POLICY to bind the predicate to the target table. Specify ADD FILTER PREDICATE for read operations. Optionally, add a block predicate for write operations. Set STATE = ON to enable the policy. Example: CREATE SECURITY POLICY rls.policy ADD FILTER PREDICATE rls.fn_filter(TenantID) ON dbo.Orders WITH (STATE = ON);.
Set session context in application
In the application code (e.g., after authentication), call sp_set_session_context to set the values used by the predicate. For example: EXEC sp_set_session_context @key = N'TenantID', @value = 42;. The application must ensure the context is set correctly for each user session. This is typically done in the database connection initialization.
Test and verify the policy
Execute queries as different users or with different session context values to verify that only appropriate rows are returned. Use SELECT * FROM sys.security_policies to confirm the policy exists. Check query plans to ensure the predicate is being applied (look for a filter operator). Also test edge cases like users with no context set (they will see no rows if the predicate fails).
In a Software-as-a-Service (SaaS) application serving multiple companies (tenants) from a single Azure SQL Database, RLS is a cornerstone of data isolation. For example, a CRM platform stores all customer records in one table with a TenantID column. Without RLS, every query would require a WHERE TenantID = @tenantID clause, which is error-prone and hard to enforce consistently. By implementing RLS with a predicate that checks SESSION_CONTEXT('TenantID'), the application sets the tenant ID once per session, and all subsequent queries automatically filter rows. This reduces code complexity and ensures no data leaks even if a developer forgets to add the WHERE clause.
Another scenario is a financial reporting system where managers should see only their direct reports' data. The database has an Employees table with a ManagerID column. The RLS predicate uses the current user's EmployeeID (obtained from SESSION_CONTEXT('EmployeeID')) to filter rows where ManagerID equals that ID. This allows the application to reuse the same queries for all managers without modification.
In production, performance considerations are important. For a table with millions of rows, the predicate column (e.g., TenantID) should be indexed. Without an index, RLS causes a full table scan for every query, leading to performance degradation. Additionally, when using SESSION_CONTEXT, the application must set the context immediately after opening the connection, before any queries. Misconfiguration, such as forgetting to set the context, results in users seeing no data (if the predicate returns false) or all data (if the predicate is not applied due to an error). Common pitfalls include using USER_NAME() instead of SESSION_CONTEXT(), which is less secure because the username can be spoofed if the application uses a single database login. Also, failing to schema-bind the predicate function causes an error when creating the policy.
On the DP-900 exam, Row-Level Security is covered under objective 2.1: 'Describe relational data security measures'. You should be able to explain what RLS is, its purpose, and how it differs from other security features like column-level security (always encrypted) or dynamic data masking. The exam does not require you to write T-SQL commands but expects you to identify the correct scenario for RLS.
Common wrong answers include: - 'RLS encrypts data at rest.' This is incorrect; encryption is handled by TDE or Always Encrypted. - 'RLS is applied to columns.' No, RLS filters rows, not columns. Column-level security is done via permissions or Always Encrypted. - 'RLS is configured at the server level.' RLS is a database-level feature, not server-level. - 'RLS requires application changes to add WHERE clauses.' Actually, RLS is transparent; no query changes are needed.
Key terms that appear verbatim: 'filter predicate', 'block predicate', 'security policy', 'inline table-valued function', 'SESSION_CONTEXT()', 'schema-bound'. The exam may present a scenario where a multi-tenant app needs to isolate tenant data in a single table, and the correct answer is 'Implement Row-Level Security'.
Edge cases: The exam loves to test that RLS does NOT apply to cross-database queries or to the sysadmin role (sysadmin sees all rows regardless). Also, remember that RLS can be bypassed by users with the ALTER ANY SECURITY POLICY permission. The exam may ask about performance impact: RLS adds a filter to every query, so indexing the predicate column is important.
To eliminate wrong answers, focus on the mechanism: RLS is about row filtering, not encryption or column masking. If the question mentions 'hiding columns' or 'encrypting data', those are different features.
RLS is a database-level feature that restricts row access based on a filter predicate.
The filter predicate is an inline table-valued function that must be schema-bound.
Use SESSION_CONTEXT() to pass application-specific context securely.
RLS applies to all queries against a protected table, including views and stored procedures.
RLS does not encrypt data; use TDE or Always Encrypted for encryption.
Index the predicate column(s) to avoid performance degradation.
RLS can be bypassed by users with ALTER ANY SECURITY POLICY permission or sysadmin role.
Block predicates can be used to prevent unauthorized inserts, updates, or deletes.
These come up on the exam all the time. Here's how to tell them apart.
Row-Level Security (RLS)
Filters entire rows from query results based on user identity.
Uses a security policy with a filter predicate (inline TVF).
Applies to all operations (SELECT, INSERT, UPDATE, DELETE) with block predicates.
Requires creation of a predicate function and security policy.
Ideal for multi-tenant data isolation.
Dynamic Data Masking (DDM)
Obfuscates specific column values in query results (e.g., masking credit card numbers).
Uses masking rules defined per column (e.g., partial, default, email).
Does not affect underlying data; only masks output for SELECT queries.
Configured via ALTER TABLE ... ALTER COLUMN ... ADD MASKED WITH (FUNCTION = '...').
Ideal for hiding sensitive data like PII from non-privileged users.
Mistake
RLS encrypts data so that unauthorized users cannot read it.
Correct
RLS does not encrypt data; it filters rows from query results. Unauthorized users cannot see the rows, but the data is still stored in plaintext (unless TDE is used). Encryption is handled by Always Encrypted or TDE.
Mistake
RLS can be applied to columns to hide sensitive column values.
Correct
RLS filters entire rows, not columns. To hide specific column values, use Dynamic Data Masking (DDM) or column-level permissions. RLS is for row-level access control.
Mistake
RLS is configured at the server level and affects all databases.
Correct
RLS policies are defined per database. Each database has its own security policies. There is no server-level RLS.
Mistake
RLS requires modifying application queries to include the filtering logic.
Correct
RLS is transparent. The database engine automatically applies the filter predicate to all queries. Application code does not need to be changed.
Mistake
Using USER_NAME() in the predicate is more secure than SESSION_CONTEXT().
Correct
USER_NAME() returns the database user, which may be the same for multiple application users (e.g., a single service account). SESSION_CONTEXT() allows the application to set a more granular context after authentication, providing finer control and better security.
Reveal each answer, then mark whether you got it right. Score 60%+ to unlock the next chapter.
Yes, RLS adds overhead because every query against a protected table must evaluate the filter predicate. However, if the predicate column is indexed, the performance impact is minimal. Complex predicates with joins or subqueries can degrade performance. In practice, for simple equality checks on indexed columns, overhead is negligible.
Yes, RLS is fully supported in Azure SQL Database, including the serverless compute tier. The same T-SQL commands apply. There are no additional limitations specific to serverless.
After opening the connection, execute a command like: EXEC sp_set_session_context @key = N'TenantID', @value = 42. In .NET, you can use SqlCommand with the connection. Ensure this runs before any queries. Example: using (var cmd = new SqlCommand("EXEC sp_set_session_context @key = N'TenantID', @value = @tenantId", conn)) { cmd.Parameters.AddWithValue("@tenantId", tenantId); cmd.ExecuteNonQuery(); }.
RLS is applied to the base table, not directly to views. However, when a view queries a table protected by RLS, the policy is enforced on the underlying table. You cannot create a security policy on a view. If you need row filtering on a view, consider creating the view with a WHERE clause or apply RLS to the base table.
If the predicate function uses SESSION_CONTEXT() and the context has not been set, the function returns NULL, which is treated as 0 (false) in the predicate, so the user sees no rows. To avoid this, ensure the application always sets the context or provide a default value in the predicate.
Yes, RLS is fully supported in Azure SQL Managed Instance, with the same T-SQL syntax and behavior as Azure SQL Database.
No, a table can have only one security policy. However, that policy can contain multiple predicates (filter and block predicates) using different functions. You can also combine logic in a single predicate function.
You've just covered Row-Level Security in Azure SQL — now see how well it sticks with free DP-900 practice questions. Full explanations included, no account needed.
Done with this chapter?