The Brief
A client running a multi-department Power BI environment came to me with a straightforward request: Finance should see Finance pages. Operations should see Operations pages. HR should see HR pages. Nobody should see pages that aren't theirs.
Simple in concept. The problem: Power BI has no native page-level security feature. You cannot assign visibility of a page to an RLS role the way you assign data filters. Pages are visible to everyone who has access to the report — full stop.
What Power BI does have is enough building blocks to construct a solid workaround. Here's how I built it.
The Architecture
The solution uses five components working together:
- →PageAccess table — a mapping table that defines which role can access which page, using a simple RoleName / PageKey structure
- →RLS roles — one per department, each filtering the PageAccess table to only their rows using a DAX row filter
- →Navigate measures — DAX measures that return the target page name when the role has access, or BLANK() when it doesn't
- →Colour measures — DAX measures that return brand blue for active buttons and light grey for inactive ones
- →Blank buttons on a Home page — individual buttons with destination and fill colour both driven by the measures above via conditional formatting (fx)
The result: active buttons navigate correctly. Inactive buttons appear greyed-out and do nothing when clicked. All destination pages are hidden from the tab bar — the Home page is the only entry point.
One Thing to Know Before You Start
Power BI has a built-in Page Navigator button (Insert → Buttons → Page Navigation) that auto-generates a full button set for your report. It looks like exactly what you need.
It isn't. The Page Navigator widget is pre-packaged with very limited customisation. You cannot drive individual button destinations or fill colours via DAX measures — which is the entire mechanism this solution relies on. Use Blank buttons instead, one per page.
The DAX
PageAccess Table
PageAccess = DATATABLE( "RoleName", STRING, "PageKey", STRING, "HasAccess", INTEGER, { { "Finance", "finance_detail", 1 }, { "Finance", "executive_summary", 1 }, { "Operations", "ops_detail", 1 }, { "HR", "hr_detail", 1 } } )
Navigate Measures
These return the exact page name when the role has access, or BLANK() when it doesn't. The button destination is set to BLANK() which disables the action entirely.
Navigate Finance = IF( COUNTROWS( FILTER(PageAccess, PageAccess[PageKey] = "finance_detail") ) > 0, "Finance Detail", BLANK() )
Repeat for Navigate Executive Summary, Navigate Operations, and Navigate HR, swapping the PageKey and return value.
Colour Measures
Two sets: fill colour and text colour. Active state is brand blue with white text. Inactive state is light grey with faded grey text.
Colour Finance = IF( COUNTROWS( FILTER(PageAccess, PageAccess[PageKey] = "finance_detail") ) > 0, "#2A5F8F", -- active: brand blue "#E0E0E0" -- inactive: light grey )
Text Colour Finance = IF( COUNTROWS( FILTER(PageAccess, PageAccess[PageKey] = "finance_detail") ) > 0, "#FFFFFF", -- active: white "#BBBBBB" -- inactive: faded grey )
Wiring Up the Buttons
For each button on the Home page:
Insert → Buttons → Blank
Do not use the Page Navigation widget. Insert individual blank buttons and set the label text manually in the Format pane.
Toggle Action on → Page navigation → fx on Destination
Click the fx icon next to Destination, set Format style to Field value, and select the corresponding Navigate measure.
Button style → Fill → fx
Select the corresponding Colour measure. This controls the button background colour based on the active RLS role.
Button style → Font colour → fx
Select the corresponding Text Colour measure so the label text also reflects the active/inactive state.
Hide all destination pages from the tab bar
Right-click each destination page tab → Hide page. Leave Home visible. Users who bypass buttons via the tab bar would undermine the entire pattern.
Enterprise Deployment — USERPRINCIPALNAME()
The static role approach above works well for small teams. For production environments, the better pattern is dynamic RLS with a single role driven by a UserMapping table fed from your identity infrastructure.
Instead of assigning users to multiple roles in the Power BI Service, you create one DynamicAccess role with a single filter:
[UserEmail] = USERPRINCIPALNAME()
The Navigate measures are updated to look up the user's role dynamically:
Navigate Finance = VAR CurrentRole = CALCULATE( SELECTEDVALUE(UserMapping[RoleName]), FILTER(UserMapping, UserMapping[UserEmail] = USERPRINCIPALNAME()) ) VAR HasAccess = COUNTROWS( FILTER( PageAccess, PageAccess[RoleName] = CurrentRole && PageAccess[PageKey] = "finance_detail" ) ) RETURN IF(HasAccess > 0, "Finance Detail", BLANK())
Where the UserMapping Table Comes From
In practice the UserMapping table is never hardcoded in Power BI. It comes from systems that already own this data:
| Source | How it reaches Power BI | Best for |
|---|---|---|
| Azure AD / Entra ID | Microsoft Graph API sync to warehouse, or Azure AD connector in Power Query | Microsoft-first enterprises |
| HR System (Workday, SAP) | ETL into data warehouse as dim_employee | Department-based access |
| Dedicated security table in warehouse | Standard connector — same as any other table | Best audit trail |
| IAM platform (Okta, SailPoint) | Pushed to data warehouse, read by Power BI | Enterprise-grade governance |
The governance principle: whoever owns the UserMapping table owns access to the report. That should be IT or the data team — not the report author.
Limitations
Sample File & Full Guide
The full step-by-step guide — covering static RLS for this exercise and dynamic RLS with USERPRINCIPALNAME() for enterprise deployment — is available on GitHub along with the sample PBIX file used in the walkthrough above.