Guides

Row-level security without killing performance

The symptom: the report was fast until security was added, and now a regional manager waits ten seconds per click. Or security was skipped because of that, and everyone sees everything. The cause is where the filter was placed. The fix is a mapping table, a filter on a dimension, and single-direction relationships.

How row-level security works

A role holds a DAX filter expression on one or more tables. When a person in that role opens a report, the engine applies the filter to every query, and the filter propagates along relationships to the fact table. Two things follow. The filter should sit on a small dimension table, not on the fact, because a filter on millions of rows is evaluated against millions of rows. And the relationships the filter travels along should be single-direction, because bi-directional ones make the engine evaluate the security filter in both directions on every visual.

The pattern

1. A mapping table from person to what they may see

One table, two columns: the user's sign-in (the email the service knows them by) and the key they are allowed. A person with three regions has three rows. Load it from wherever this truth lives (HR system, a spreadsheet the business owns, a table in the warehouse); do not type it into the model.

UserRegion
UserEmail                RegionKey
ana@example.com          NE
ana@example.com          SE
raj@example.com          W

2. One role with a dynamic filter

Create a single role, usually called something like Viewer, with a filter on the mapping table that keeps the current user's rows.

-- Filter on UserRegion (the mapping table), not on Sales
[UserEmail] = USERPRINCIPALNAME()

Relate UserRegion[RegionKey] to Region[RegionKey] (many to one), and Region to Sales as usual. Set the relationship from the mapping table to the region dimension to apply the security filter in both directions, which is the one place a bi-directional setting is correct, and leave every other relationship single-direction. The filter goes mapping table, then region, then sales: a handful of rows, then hundreds, then the fact.

3. Add everyone to that one role

In the service, assign the security group that contains all report users to the Viewer role. Membership of the role says "security applies to you"; the mapping table says what you see. Adding a person means a row in the table, not a change to the model.

4. Test as a user before publishing

Desktop's "View as" lets you pick the role and type an email. Check three people: one with one region, one with several, one who is not in the mapping table at all. The last should see nothing, and that should be a deliberate choice; if new starters must see something by default, add a default row for them rather than loosening the filter.

The three ways RLS makes a report slow

  • The filter is on the fact table. [Region] = LOOKUPVALUE(...) on Sales evaluates per row of Sales. Move it to the dimension.
  • Bi-directional relationships elsewhere in the model. With security on, every one of them doubles the work of the security filter. Set them to single direction and use CROSSFILTER inside the one measure that needs the other direction.
  • Aggregation tables that do not cover the security column. If the summary table has no region column, the engine cannot use it under RLS and falls back to the detail for every query. Include the security key in the aggregation's grouping.

Fixed, a model with RLS runs within a small margin of the same model without it. The five fixes walkthrough covers the relationship and aggregation points in detail.

What RLS does not do

  • It does not protect the data from people who have Build or Write permission on the model; they can see everything. Viewers get RLS; authors do not.
  • It does not hide measures or pages. A measure that sums all regions still sums only the permitted rows, which is usually what you want, but a visual titled "Total company revenue" will show a regional number to a regional user. Name things honestly.
  • It does not reach outside Power BI. If the same data is read from a notebook or a SQL endpoint, the rule has to exist there too, which is the argument for putting it in the lakehouse (row filters in Unity Catalog) when the data has several consumers.

When to ask for help

If the rules are complex (hierarchies, managers who see their reports' regions, exceptions that change monthly), the design of the mapping table is the whole problem and a day of work with the person who owns the rules usually settles it. If the report is slow with RLS and you cannot see why, the three causes above cover almost every case we have met.

Related: Power BI performance FAQ, row-level security in the glossary.

Did the report slow down the day security went on?

Send the model's relationship view as a screenshot. We reply within a working day with which of the three causes it is.