Learn how to embed fully interactive Power BI reports into web applications using service principal authentication, the Power BI REST API, and the powerbi-client JavaScript SDK. Covers multi-tenant RLS enforcement, token refresh, and programmatic report control from filter application to bookmark management.

Picture this: your company has built a genuinely excellent Power BI report — clean data model, well-designed visuals, meaningful DAX measures — and now your product team wants it living inside the customer-facing web application. Not linked out to the Power BI service, not in an iframe that breaks on mobile, but actually embedded, white-labeled, responding to user interactions in the app, and secured so that customer A never sees customer B's data. That's a real engineering problem, and it's harder than it looks the first time you encounter it.
Power BI Embedded Analytics is Microsoft's answer to exactly this problem. It lets you programmatically generate access tokens, render fully interactive reports inside a div element in your web application, and control those reports through a JavaScript API that gives you fine-grained control over filters, bookmarks, page navigation, and events. The catch is that the setup involves Azure Active Directory (now Microsoft Entra ID), service principals, Power BI workspaces, capacity licensing, and a JavaScript SDK — all of which have to work together correctly before a single pixel renders. When one of those pieces is misconfigured, you get a blank div and an unhelpful error message.
By the end of this lesson, you will know how to build a complete embedded analytics integration from scratch. You'll understand the authentication flow deeply enough to debug it when it breaks, control embedded reports programmatically through the JavaScript API, apply dynamic filters from your application's context, and implement the Row-Level Security patterns that make multi-tenant embedded analytics actually secure. Here are the specific things you'll be able to do:
What you'll learn:
powerbi-client JavaScript libraryThis lesson assumes you're comfortable with the following:
Before touching a single line of code, you need a clear mental model of how Power BI Embedded authentication works. Most integration failures happen because someone skips this step and starts copying token-generation code without understanding what it's actually doing.
There are two embedding approaches Microsoft documents:
Embed for your organization ("User owns data"): The end user authenticates with their own Power BI account. The report token is tied to that user's permissions. This is appropriate for internal portals where all users have Power BI licenses.
Embed for your customers ("App owns data"): Your application authenticates as a service principal. You generate embed tokens on behalf of your users. This is the approach used for external-facing products, ISV solutions, and any scenario where your end users don't have Power BI accounts. It's also the harder one to set up, and it's what we'll focus on in this lesson.
The token flow for "App owns data" looks like this:
powerbi-client JavaScript SDK to render the report inside a container element, passing it the embed token and URL.The service principal is never exposed to the browser. It lives only on your server. This is a critical architectural point: if you ever find yourself putting your client secret or AAD access token into front-end JavaScript, something has gone badly wrong.
Warning: Never expose your service principal's client secret or the AAD access token to the browser. These credentials allow anyone who obtains them to call the Power BI REST API with full permissions. The embed token is the only credential that should reach the client side, and even that should be treated as sensitive.
Open the Azure portal and navigate to Microsoft Entra ID (formerly Azure Active Directory). In the left navigation, select App registrations, then New registration.
Give your application a descriptive name — something like PowerBI-Embedded-Prod rather than MyApp. For supported account types, choose Accounts in this organizational directory only unless you have a specific reason for multi-tenant. Leave the redirect URI blank for now; service principals using client credentials don't use redirect URIs.
After registration, copy three values from the application's Overview page — you'll need all three:
clientIdtenantIdNext, create a client secret. Go to Certificates & secrets, select Client secrets, then New client secret. Set an expiry that matches your organization's key rotation policies (12 or 24 months, with a reminder to rotate before expiry). Copy the secret value immediately — you cannot retrieve it again after navigating away.
Tip: In production systems, prefer certificate-based authentication over client secrets. Certificates are harder to accidentally leak through log files or environment variable dumps, and they support HSM-backed key storage in Azure Key Vault. The MSAL libraries support certificate thumbprint authentication with nearly identical code to secret-based auth.
Now configure API permissions. In your app registration, select API permissions, then Add a permission. Choose Power BI Service from the list of Microsoft APIs. Select Application permissions (not delegated — delegated permissions are for user-context auth). Add these permissions:
Dataset.ReadWrite.AllReport.ReadWrite.AllWorkspace.Read.AllAfter adding them, click Grant admin consent for [your tenant]. Without admin consent, the application permissions won't work.
Finally, you need to enable service principal access in the Power BI admin portal. Sign in to app.powerbi.com, navigate to Settings (gear icon) > Admin portal > Tenant settings. Find Developer settings and within it, Allow service principals to use Power BI APIs. Enable this setting, and either apply it to the entire organization or to a specific security group that contains your registered application.
Your service principal now has permission to call Power BI APIs, but it still needs access to the specific workspace containing the reports you want to embed.
Navigate to the workspace in Power BI service, select Workspace settings, then the Access tab. Add the service principal by searching for its application name (the one you used in Entra ID). Assign it the Member or Contributor role. The Viewer role is not sufficient for generating embed tokens — the service principal needs at least Member level access.
Note: If your organization uses Premium capacity (P-SKU or A-SKU), make sure the workspace is assigned to that capacity before attempting to generate embed tokens. Embed tokens for external users require Premium capacity or Power BI Embedded capacity (A-SKU). Trying to embed without capacity assignment will result in a
PowerBINotAuthorizedExceptionor a 403 response from the token generation API.
Collect the workspace ID and report ID. In Power BI service, navigate to your report and look at the URL. It follows the pattern:
https://app.powerbi.com/groups/{workspaceId}/reports/{reportId}/ReportSection
Both GUIDs in that URL are what you need. Save them — you'll pass them to the embed token API.
Now we build the server component. I'll show this in Node.js since it's the most common web application language, but the patterns translate directly to C#, Python, or any other language with HTTP client capabilities.
Install the required packages:
npm install @azure/msal-node node-fetch express
Here's a complete embed token service. Notice how each step maps to the architecture we described:
// embedService.js
const msal = require('@azure/msal-node');
const msalConfig = {
auth: {
clientId: process.env.POWERBI_CLIENT_ID,
clientSecret: process.env.POWERBI_CLIENT_SECRET,
authority: `https://login.microsoftonline.com/${process.env.POWERBI_TENANT_ID}`,
},
};
const cca = new msal.ConfidentialClientApplication(msalConfig);
const POWERBI_SCOPE = ['https://analysis.windows.net/powerbi/api/.default'];
async function getAadToken() {
const result = await cca.acquireTokenByClientCredential({
scopes: POWERBI_SCOPE,
});
return result.accessToken;
}
async function getEmbedToken(workspaceId, reportId, aadToken, effectiveIdentity = null) {
const reportUrl = `https://api.powerbi.com/v1.0/myorg/groups/${workspaceId}/reports/${reportId}`;
// First, get the report metadata to find the dataset ID
const reportResponse = await fetch(reportUrl, {
headers: { Authorization: `Bearer ${aadToken}` },
});
if (!reportResponse.ok) {
const errorBody = await reportResponse.text();
throw new Error(`Failed to fetch report metadata: ${reportResponse.status} - ${errorBody}`);
}
const reportData = await reportResponse.json();
const datasetId = reportData.datasetId;
const embedUrl = reportData.embedUrl;
// Build the token request body
const tokenRequestBody = {
reports: [{ id: reportId }],
datasets: [{ id: datasetId }],
targetWorkspaces: [{ id: workspaceId }],
};
// Add effective identity for Row-Level Security
if (effectiveIdentity) {
tokenRequestBody.identities = [
{
username: effectiveIdentity.username,
roles: effectiveIdentity.roles,
datasets: [datasetId],
},
];
}
const tokenResponse = await fetch(
'https://api.powerbi.com/v1.0/myorg/GenerateToken',
{
method: 'POST',
headers: {
Authorization: `Bearer ${aadToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(tokenRequestBody),
}
);
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text();
throw new Error(`Failed to generate embed token: ${tokenResponse.status} - ${errorBody}`);
}
const tokenData = await tokenResponse.json();
return {
token: tokenData.token,
tokenId: tokenData.tokenId,
expiration: tokenData.expiration,
embedUrl: embedUrl,
reportId: reportId,
};
}
module.exports = { getAadToken, getEmbedToken };
Now wire this into an Express endpoint:
// server.js
const express = require('express');
const { getAadToken, getEmbedToken } = require('./embedService');
const app = express();
app.use(express.json());
// This endpoint is called by your front-end to get embed credentials
// It should be protected by your own application's authentication middleware
app.get('/api/embed-config', async (req, res) => {
try {
// In a real application, authenticate the requesting user here
// and determine their effective identity/roles before calling getEmbedToken
const currentUser = req.user; // from your auth middleware
const workspaceId = process.env.POWERBI_WORKSPACE_ID;
const reportId = process.env.POWERBI_REPORT_ID;
const aadToken = await getAadToken();
// Map your application's user to Power BI RLS identity
const effectiveIdentity = {
username: currentUser.email,
roles: currentUser.pbiRoles, // e.g., ['RegionManager', 'EMEA']
};
const embedConfig = await getEmbedToken(
workspaceId,
reportId,
aadToken,
effectiveIdentity
);
res.json(embedConfig);
} catch (error) {
console.error('Embed token generation failed:', error.message);
res.status(500).json({ error: 'Failed to generate embed configuration' });
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
Notice the pattern: the effectiveIdentity object is constructed from your application's authenticated user context, not from anything the browser sends. This is important. If your application allows the browser to dictate which RLS roles apply, you've created a privilege escalation vulnerability.
With the server providing embed tokens, we can now build the front-end. Install the Power BI JavaScript client library:
npm install powerbi-client
Or via CDN in an HTML page:
<script src="https://cdn.jsdelivr.net/npm/powerbi-client@2.23.0/dist/powerbi.min.js"></script>
The powerbi-client library exports a service.Service class and a global powerbi object. Here's a complete embedding implementation that demonstrates all the key patterns:
<!-- In your HTML -->
<div id="report-container" style="height: 600px; width: 100%;"></div>
// embedReport.js
import * as pbi from 'powerbi-client';
const powerbi = new pbi.service.Service(
pbi.factories.hpmFactory,
pbi.factories.wpmpFactory,
pbi.factories.routerFactory
);
let embeddedReport = null;
async function fetchEmbedConfig() {
const response = await fetch('/api/embed-config', {
headers: {
// Include your application's auth token here
Authorization: `Bearer ${getAppToken()}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch embed config: ${response.status}`);
}
return response.json();
}
async function embedReport() {
const embedConfig = await fetchEmbedConfig();
const reportContainer = document.getElementById('report-container');
const config = {
type: 'report',
id: embedConfig.reportId,
embedUrl: embedConfig.embedUrl,
accessToken: embedConfig.token,
tokenType: pbi.models.TokenType.Embed, // NOT Aad — this is the embed token
settings: {
panes: {
filters: { expanded: false, visible: true },
pageNavigation: { visible: false }, // hide default page nav, we'll build our own
},
background: pbi.models.BackgroundType.Transparent,
navContentPaneEnabled: false,
},
};
// Embed the report and store the reference
embeddedReport = powerbi.embed(reportContainer, config);
// Attach event handlers
embeddedReport.on('loaded', handleReportLoaded);
embeddedReport.on('rendered', handleReportRendered);
embeddedReport.on('error', handleReportError);
embeddedReport.on('dataSelected', handleDataSelected);
return embeddedReport;
}
async function handleReportLoaded() {
console.log('Report loaded');
// The report DOM is ready but data may still be loading
// Good time to apply initial filters
await applyInitialFilters();
}
async function handleReportRendered() {
console.log('Report fully rendered with data');
// Hide your loading spinner here
document.getElementById('loading-spinner').style.display = 'none';
}
function handleReportError(event) {
const error = event.detail;
console.error('Embedded report error:', error);
// Log to your telemetry system
// Display a user-friendly error message
}
async function handleDataSelected(event) {
const data = event.detail;
console.log('User selected data:', data);
// You can use this to drive interactions in other parts of your UI
// For example, updating a sidebar with details about the selected data point
}
export { embedReport, embeddedReport };
Key insight: The
loadedevent fires when the report structure is initialized but before data has been fetched and rendered. Therenderedevent fires when the first complete render with data is done. Apply filters onloaded, hide loading spinners onrendered. Getting this sequence wrong leads to race conditions where filters are applied before the report is ready.
This is where embedded analytics gets genuinely powerful. The powerbi-client API lets your application control the embedded report — not just display it.
You can apply report-level, page-level, or visual-level filters programmatically. The filter objects follow the same schema as Power BI's internal filter model:
async function applyInitialFilters() {
if (!embeddedReport) return;
// Basic filter: show only data for a specific region
const regionFilter = {
$schema: 'http://powerbi.com/product/schema#basic',
target: {
table: 'Geography',
column: 'Region',
},
operator: 'In',
values: ['North America', 'EMEA'],
filterType: pbi.models.FilterType.Basic,
};
// Advanced filter: date range using two conditions
const dateFilter = {
$schema: 'http://powerbi.com/product/schema#advanced',
target: {
table: 'Date',
column: 'DateKey',
},
logicalOperator: 'And',
conditions: [
{ operator: 'GreaterThanOrEqual', value: 20230101 },
{ operator: 'LessThanOrEqual', value: 20231231 },
],
filterType: pbi.models.FilterType.Advanced,
};
try {
// Apply multiple filters at once — more efficient than applying one by one
await embeddedReport.setFilters([regionFilter, dateFilter]);
console.log('Filters applied successfully');
} catch (error) {
console.error('Failed to apply filters:', error);
}
}
// Remove all filters
async function clearAllFilters() {
if (!embeddedReport) return;
await embeddedReport.removeFilters();
}
// Update a single filter without touching others
async function filterByCustomer(customerId) {
if (!embeddedReport) return;
const customerFilter = {
$schema: 'http://powerbi.com/product/schema#basic',
target: {
table: 'Customers',
column: 'CustomerID',
},
operator: 'In',
values: [customerId],
filterType: pbi.models.FilterType.Basic,
};
// Get current filters, add or update the customer filter
const currentFilters = await embeddedReport.getFilters();
const otherFilters = currentFilters.filter(
f => !(f.target?.table === 'Customers' && f.target?.column === 'CustomerID')
);
await embeddedReport.setFilters([...otherFilters, customerFilter]);
}
The table and column values in filter targets must match the names in your Power BI data model exactly — not the display names you might have set in the report. If you've renamed a column in Power Query or the model, use the model name.
If you've hidden the built-in page navigation pane (which you often will in embedded scenarios to maintain your app's navigation structure), you need to handle page switching programmatically:
async function getReportPages() {
if (!embeddedReport) return [];
const pages = await embeddedReport.getPages();
return pages.map(page => ({
name: page.name, // internal page name (e.g., "ReportSection3")
displayName: page.displayName, // human-readable name
isActive: page.isActive,
}));
}
async function navigateToPage(pageName) {
if (!embeddedReport) return;
const pages = await embeddedReport.getPages();
const targetPage = pages.find(p => p.name === pageName);
if (!targetPage) {
console.error(`Page "${pageName}" not found in report`);
return;
}
await targetPage.setActive();
}
// Build a custom navigation tab bar
async function buildCustomNavigation() {
const pages = await getReportPages();
const navContainer = document.getElementById('report-nav');
navContainer.innerHTML = '';
pages.forEach(page => {
const tab = document.createElement('button');
tab.textContent = page.displayName;
tab.classList.toggle('active', page.isActive);
tab.addEventListener('click', () => navigateToPage(page.name));
navContainer.appendChild(tab);
});
}
For more sophisticated page flow patterns, the same concepts you use within Power BI Desktop — like the bookmark-based navigation patterns described in Build Professional Navigation in Power BI: Bookmarks, Buttons & Page Flow Mastery — can be triggered programmatically through the JavaScript API, giving you a unified navigation system that spans your app and the embedded report.
async function captureCurrentBookmark() {
if (!embeddedReport) return null;
const capturedBookmark = await embeddedReport.bookmarksManager.capture();
return capturedBookmark.state; // a string you can store and restore later
}
async function applyBookmark(bookmarkState) {
if (!embeddedReport) return;
await embeddedReport.bookmarksManager.applyState(bookmarkState);
}
// Apply a named bookmark defined in the report
async function applyNamedBookmark(bookmarkName) {
if (!embeddedReport) return;
const bookmarks = await embeddedReport.bookmarksManager.getBookmarks();
const target = bookmarks.find(b => b.name === bookmarkName);
if (target) {
await embeddedReport.bookmarksManager.apply(target.name);
}
}
This is useful for building "saved view" functionality in your application — capture a user's current filter state and bookmark position, store it in your database, and restore it the next time they visit.
The API goes down to individual visuals when needed:
async function getVisualData(pageName, visualName) {
if (!embeddedReport) return null;
const pages = await embeddedReport.getPages();
const page = pages.find(p => p.name === pageName);
if (!page) return null;
const visuals = await page.getVisuals();
const targetVisual = visuals.find(v => v.name === visualName);
if (!targetVisual) return null;
// Export visual data as a result object
const result = await targetVisual.exportData(pbi.models.ExportDataType.Summarized);
return result.data; // CSV string of the summarized data
}
The most critical security capability in embedded analytics is Effective Identity — the mechanism that tells Power BI "render this report as if this user is logged in, with these RLS roles applied."
If your application serves multiple customers (multi-tenant), and each customer should only see their own data, Effective Identity combined with Row-Level Security in your Power BI dataset is how you enforce that isolation. The RLS roles must be defined in your dataset before the embed token can reference them — that configuration is done in Power BI Desktop and implementing those dynamic RLS rules is a prerequisite to this step.
Here's the trust model:
// A more complete effective identity implementation
function buildEffectiveIdentity(authenticatedUser) {
// authenticatedUser comes from your application's own auth system
// (JWT claims, session data, etc.) — never from request parameters
const identity = {
username: authenticatedUser.email,
roles: [],
datasets: [process.env.POWERBI_DATASET_ID],
};
// Map your application's permission model to Power BI RLS roles
if (authenticatedUser.organizationId) {
// Use a dynamic username that matches your DAX RLS expression
// The DAX role filter might be: [OrganizationID] = USERNAME()
identity.username = authenticatedUser.organizationId.toString();
identity.roles = ['OrganizationMember'];
}
if (authenticatedUser.isRegionalManager) {
identity.roles.push('RegionalManager');
// RLS role might restrict to manager's assigned regions
}
if (authenticatedUser.isSuperAdmin) {
// Super admins see all data — don't add any restricting roles
// But you still need to provide an identity; use a special bypass role
// that has no WHERE clause in its DAX filter
identity.roles = ['GlobalAdmin'];
}
return identity;
}
Warning: If your Power BI dataset uses dynamic RLS rules — where the DAX expression uses
USERNAME()orUSERPRINCIPALNAME()to filter data — then theusernamefield in the effective identity is what gets returned by those DAX functions. Design your identity mapping carefully. If you pass an email address, your DAX must compare against email addresses. If you pass a customer ID integer as a string, your DAX must compare against that. Consistency between your token generation code and your DAX rules is essential.
A common pattern for multi-tenant ISV scenarios is to pass the tenant/organization ID as the username and use a single TenantMember role with a DAX filter like this in your Power BI dataset:
// In the Power BI dataset RLS role filter for the 'Organizations' table:
[OrganizationID] = VALUE(USERNAME())
This means every tenant's data is filtered to their organization's records, and you never have to create new RLS roles for new customers — just pass their organization ID as the username when generating the token.
Embed tokens expire. The default expiry is one hour, though the exact expiry is returned in the expiration field of the token generation response. If a user is viewing an embedded report when the token expires, the report will stop refreshing and eventually display an error.
The powerbi-client library fires a tokenExpired event before the token actually expires, giving you a window to refresh it silently:
embeddedReport.on('tokenExpired', async () => {
console.log('Embed token expiring, refreshing...');
try {
const newConfig = await fetchEmbedConfig();
await embeddedReport.setAccessToken(newConfig.token);
console.log('Token refreshed successfully');
} catch (error) {
console.error('Failed to refresh embed token:', error);
// Show user a message and offer a page reload
showTokenRefreshError();
}
});
setAccessToken() replaces the token without re-embedding the report or losing the user's current state (active page, applied filters, etc.). This is much better than destroying and re-embedding the report.
Tip: The
tokenExpiredevent fires approximately 2 minutes before the actual expiry. Use this window to call your server, generate a new token, and callsetAccessToken(). If your server's token generation is slow (due to cold AAD authentication), consider caching your AAD access token server-side. AAD tokens are valid for one hour and can be cached and reused across many embed token requests.
A few production-critical optimizations:
Cache AAD tokens server-side. The MSAL library does this automatically in memory, but if you're running multiple server instances (which you should be in production), use a distributed cache like Redis. Fetching a new AAD token adds ~200-400ms to every token generation request.
Use the GenerateToken V2 API for bulk operations. If your page embeds multiple reports, the V2 API (/v1.0/myorg/GenerateToken) accepts multiple reports and datasets in a single request, returning a single token that works for all of them. This is far more efficient than making N sequential token generation calls.
Pre-fetch embed config. Don't wait for the user to click on a report before fetching the embed config. If you know the user will need an embedded report on page load, fetch the config in parallel with your other page data.
Consider the first render time. Power BI reports can take 2-8 seconds to initially render, depending on the data model complexity and whether caching is warm. Always show a loading state. For DirectQuery models, review the guidance in Optimizing Power BI Report Performance: Query Reduction, Aggregations, and DirectQuery Tuning — every optimization you make in the dataset directly improves the embedded experience too.
// Server-side: Generate one token for multiple reports
async function getMultiReportEmbedToken(workspaceId, reportConfigs, aadToken) {
const tokenRequestBody = {
reports: reportConfigs.map(r => ({ id: r.reportId })),
datasets: [...new Set(reportConfigs.map(r => r.datasetId))].map(id => ({ id })),
targetWorkspaces: [{ id: workspaceId }],
};
const tokenResponse = await fetch(
'https://api.powerbi.com/v1.0/myorg/GenerateToken',
{
method: 'POST',
headers: {
Authorization: `Bearer ${aadToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(tokenRequestBody),
}
);
const tokenData = await tokenResponse.json();
// Return the single token to be used for all report embeds
return tokenData.token;
}
On the front end, all your powerbi.embed() calls use the same token value, and Power BI service handles routing correctly.
In some scenarios you want to embed a single visual from a report rather than the entire report — useful for embedding a KPI card or a specific chart inline with your application's own content:
async function embedSingleVisual(containerElement, reportId, pageName, visualName) {
const embedConfig = await fetchEmbedConfig(); // your server endpoint
const config = {
type: 'visual',
id: reportId,
embedUrl: embedConfig.embedUrl,
accessToken: embedConfig.token,
tokenType: pbi.models.TokenType.Embed,
pageName: pageName, // internal page name
visualName: visualName, // internal visual name (GUID-like string)
settings: {
background: pbi.models.BackgroundType.Transparent,
},
};
return powerbi.embed(containerElement, config);
}
The visual name is the internal name assigned by Power BI, which you can find through the getVisuals() API call or by inspecting the report with the JavaScript API.
One of the most powerful integration patterns is driving your application's UI in response to user interactions inside the embedded report. For example, when a user clicks a bar in a sales chart, your app could update a details panel outside the report iframe:
embeddedReport.on('dataSelected', async (event) => {
const selection = event.detail;
if (!selection || !selection.dataPoints || selection.dataPoints.length === 0) {
// User deselected — reset the sidebar
resetDetailSidebar();
return;
}
// Extract the selected values from the event
const selectedValues = selection.dataPoints.map(dp => {
const identities = dp.identity;
return identities.reduce((acc, identity) => {
acc[identity.target.column] = identity.equals;
return acc;
}, {});
});
// Call your own API with the selected context
const details = await fetchDetailsForSelection(selectedValues);
updateDetailSidebar(details);
});
This bidirectional integration — where user actions in the Power BI report drive behavior in the surrounding application — is what elevates embedded analytics from "an iframe with a report" to a genuinely integrated experience.
Build a complete embedded analytics prototype using the following scenario:
Scenario: You're building a customer portal for a SaaS company. Each customer (tenant) should see only their own usage and billing data in an embedded Power BI report. The portal has its own navigation, so the report's built-in navigation panes should be hidden. Customers should be able to download a PDF of the report using a button in your app's UI.
Exercise steps:
Create the dataset and report. In Power BI Desktop, build a simple report using a data model that includes a CustomerID column on your main fact table. Create an RLS role called CustomerView with the DAX filter [CustomerID] = VALUE(USERNAME()) on the fact table. Publish to a workspace.
Set up service principal authentication. Register an app in Entra ID, create a client secret, grant Power BI API permissions with admin consent, enable service principal access in the Power BI admin portal, and add the service principal as a workspace Member.
Build the server endpoint. Create a Node.js Express server with an /api/embed-config endpoint. The endpoint should accept the authenticated user's customer ID from your application's session (not from the request body — imagine it comes from a verified JWT), and pass it as the username in the effective identity with the CustomerView role.
Build the front-end embedding. Create an HTML page with a container div for the report. On page load, fetch the embed config from your server and embed the report using powerbi-client. Hide all panes. Build a custom navigation bar that reads page names from the getPages() API. Handle the tokenExpired event with a silent refresh.
Add an export button. When the user clicks "Export to PDF" in your app's UI, call the Power BI REST API's export endpoint to trigger an asynchronous export. Poll for completion and download the file. The export API endpoint is POST /v1.0/myorg/groups/{groupId}/reports/{reportId}/ExportTo with a body of { "format": "PDF" }.
Test the RLS isolation. Simulate two different customer sessions by changing the customer ID you pass as the effective identity username. Verify that each session sees only the correct customer's data.
Success criteria: The report loads within a reasonable time, page navigation works through your custom nav bar, the token refresh happens silently when the token expires (you can test this by reducing the expiry), and switching the customer ID in your test session shows a completely different data set.
This is the most common error and has three likely causes:
This usually means the dataset isn't in a workspace with Premium or Embedded capacity. Check the workspace's capacity assignment.
Verify that the RLS role is defined on the dataset (not just the report), and that your effective identity's roles array exactly matches the role name as defined in Power BI Desktop. Role names are case-sensitive in this context.
This error from the token generation API means the role name you specified in the token request doesn't exist in the dataset. Double-check the role name in Power BI Desktop under Modeling > Manage Roles.
Check that the container element exists in the DOM when embed() is called. If you're calling it before the DOM is ready (e.g., in a script that runs before DOMContentLoaded), the function call will silently fail. Also verify that you're not calling embed() on the same container element twice — if the container already has an embedded component, call powerbi.reset(container) first.
The most common cause is a mismatch between the filter target's table and column values and the actual names in the data model. The display name visible in the report may differ from the model name. Use the powerbi-client API to call embeddedReport.getFilters() on an existing filter to see the exact schema shape the API expects.
Note: If you're embedding reports that use composite models — combining DirectQuery and Import mode data — be aware that some programmatic features, particularly
exportData()on individual visuals, may behave differently or be unavailable for DirectQuery visuals. The limitations are documented in the Power BI composite models documentation, and understanding how storage modes interact will help you anticipate them.
If the tokenExpired event fires but your refresh doesn't work, check that your fetchEmbedConfig() call is actually completing. If your server's AAD token has expired, the server will fail to generate a new embed token. Add logging to your server's token generation endpoint to verify it's being called and succeeding.
Before going to production with an embedded analytics implementation, verify every item on this list:
/api/embed-config endpoint protected by your application's authentication middlewaretokenExpired event handlerYou've built a complete foundation for production-grade embedded analytics. The key architectural principles to internalize:
powerbi-client SDK needs.Where to go from here:
The reports you embed are only as good as their underlying design. The visual design patterns in Building Interactive Visuals: Advanced Charts, Maps, and Custom Formatting in Power BI apply directly to embedded contexts. Governance at scale — tracking who is viewing which embedded reports and how often — is covered in Mastering Power BI Usage Metrics and Audit Logs: Tracking Report Adoption, User Activity, and Governance at Scale. And when your embedded reports are ready for promotion from development to production, the deployment pipelines workflow automates that process in a way that's compatible with the workspace-based permission model we've built here.
Embedded analytics is one of those capabilities that, once you've shipped it correctly, becomes a genuine competitive differentiator. The combination of Power BI's modeling and visualization power with your application's context, identity, and UX is something your users will actually use — which is the whole point.