What is embedding for external users?

This page describes how embedding for external users works, how to configure your Azure Databricks workspace for secure sharing of embedded dashboards, and how to use sample applications to get started. Embedding for external users uses a service principal and scoped access tokens to authenticate and authorize access to embedded dashboards. This approach lets you share dashboards with viewers outside of your organization, such as partners and customers, without provisioning Azure Databricks accounts for those users.

To learn about other embedding options, including embedding dashboards for users within your organization, see Embed a dashboard.

How embedding for external users works

The diagram and numbered steps that follow explain how users are authenticated and dashboards are populated with user-scoped results when you embed a dashboard for external users.

A flow chart showing the necessary token exchanges across your application and the Databricks workspace.

  1. User authentication and request: The user signs in to your application. Your application's frontend sends an authenticated request to your server for a dashboard access token.
  2. Service principal authentication: Your server uses the service principal secret to request and receive an OAuth token from the Databricks server. This is a broadly scoped token that can call all dashboard APIs that Azure Databricks has access to on behalf of the service principal. Your server calls the /tokeninfo endpoint using this token, passing in basic user information, such as external_viewer_id and external_value. See Securely present dashboards to individual users.
  3. User-scoped token generation: Using the response from the /tokeninfo endpoint and the Databricks OpenID Connect (OIDC) endpoint, your server generates a new tightly-scoped token that encodes the user information you have passed in.
  4. Dashboard rendering and data filtering: The application page instantiates DatabricksDashboard from @databricks/aibi-client and passes the user-scoped token during construction. The dashboard renders with the user's context. This token authorizes access, supports auditing with external_viewer_id, and carries external_value for data filtering. Queries in dashboard datasets can reference __aibi_external_value to apply per-user filters, ensuring each viewer only sees data they are allowed to view.

Ask Genie is not available in external embedding

The Ask Genie button is not supported in embedding for external users. If you want to provide natural language data querying capabilities to external users, use the Genie Conversation API instead. The Conversation API lets you integrate Genie functionality into your application programmatically, independent of dashboard embedding.

For dashboards embedded with basic embedding, Ask Genie is available. See Ask Genie in embedded dashboards.

Securely present dashboards to individual users

Configure your application server to generate a unique user-scoped token for each user based on their external_viewer_id. This enables you to track dashboard views and usage through audit logs. The external_viewer_id is paired with an external_value, which acts as a global variable that can be inserted into SQL queries used in dashboard datasets. This enables you to filter the data displayed on the dashboard for each user.

external_viewer_id is passed to your dashboard audit logs and must not include personally identifiable information. This value should also be unique per user.

external_value is used in query processing and can include personally identifiable information.

Note

The combined size of external_viewer_id and external_value must not exceed 1 KB.

The following example demonstrates how to use the external value as a filter in dataset queries:

SELECT *
FROM sales
WHERE region = __aibi_external_value

Setup overview

This section includes a high-level conceptual overview of the steps you need to perform to set up for embedding a dashboard in an external location.

To embed a dashboard in an external application, you first create a service principal in Azure Databricks and generate a secret. The service principal must be granted read access to the dashboard and its underlying data. Your server uses the service principal secret to retrieve a token that can access dashboard APIs on behalf of the service principal. With this token, the server calls the /tokeninfo API endpoint, an OpenID Connect (OIDC) endpoint that returns basic user profile information, including the external_value and external_viewer_id values. These values let you associate requests with individual users.

Using the token obtained from the service principal, your server generates a new token scoped to the specific user who is accessing the dashboard. This user-scoped token is passed to the application page, where the application instantiates the DatabricksDashboard object from the @databricks/aibi-client library. The token carries user-specific information that supports auditing and enforces filtering so that each user only sees the data they are authorized to access. From the user's perspective, logging in to the application automatically provides access to the embedded dashboard with the correct data visibility.

Rate limits and performance considerations

External embedding has a rate limit of 20 dashboard loads per second. You can open more than 20 dashboards at once, but no more than 20 can start loading simultaneously.

Prerequisites

To implement external embedding, ensure you meet the following prerequisites:

Step 1: Create a service principal

Create a service principal to act as the identity for your external application within Azure Databricks. This service principal authenticates requests on behalf of your application.

To create a service principal:

  1. As a workspace admin, log in to the Azure Databricks workspace.
  2. Click your username in the top bar of the Azure Databricks workspace and select Settings.
  3. Click Identity and access in the left pane.
  4. Next to Service principals, click Manage.
  5. Click Add service principal.
  6. Click Add new.
  7. Enter a descriptive name for the service principal.
  8. Click Add.
  9. Open the service principal you just created from the Service principals listing page. Use the Filter text entry field to search for it by name, if necessary.
  10. On the Service principal details page, record the Application Id. Verify that the Databricks SQL access and Workspace access checkboxes are selected.

Step 2: Create an OAuth secret

Generate a secret for the service principal and collect the following configuration values, which you'll need for your external application:

  • Service principal (client) ID
  • Client secret

The service principal uses an OAuth secret to verify its identity when requesting an access token from your external application.

To generate a secret:

  1. Click Secrets on the Service principal details page.
  2. Click Generate secret.
  3. Enter a lifetime value for the new secret in days (e.g., between 1 and 730 days).
  4. Copy the secret immediately. You cannot view this secret again after you leave this screen.

Step 3: Assign permissions to your service principal

The service principal you created acts as the identity that provides dashboard access through your application. Its permissions apply only if the dashboard is not published with shared data permissions. If shared data permissions are used, the publisher’s credentials access data. For more details and recommendations, see Embedding authentication approaches.

  1. Click Dashboards in the workspace sidebar to open the dashboard listing page.
  2. Click the name of the dashboard you want to embed. The published dashboard opens.
  3. Click Share.
  4. Use the text entry field in the Sharing dialog to find your service principal and then click on it. Set the permission level to CAN RUN. Then, click Add.
  5. Record the dashboard ID. You can find the dashboard ID in the dashboard's URL (e.g., https://<your-workspace-url>/dashboards/<dashboard-id>). See Databricks workspace details.

Note

If you publish a dashboard with individual data permissions, you must grant your service principal access to the data used in the dashboard. Compute access always uses the publisher’s credentials, so you do not need to grant compute permissions to the service principal.

To read and display data, the service principal must have at least SELECT privileges on the tables and views referenced in the dashboard. See Who can manage privileges?.

Step 4: Use the example app to authenticate and generate tokens

Use an example application to practice externally embedding your dashboard. The applications include instructions and code that initiates the necessary token exchange to generate scoped tokens. The following code blocks have no dependencies. Copy and save one of the following applications.

Python

Copy and save this in a file named example.py.

#!/usr/bin/env python3

import os
import sys
import json
import base64
import urllib.request
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler

# -----------------------------------------------------------------------------
# Config
# -----------------------------------------------------------------------------
CONFIG = {
    "instance_url": os.environ.get("INSTANCE_URL"),
    "dashboard_id": os.environ.get("DASHBOARD_ID"),
    "service_principal_id": os.environ.get("SERVICE_PRINCIPAL_ID"),
    "service_principal_secret": os.environ.get("SERVICE_PRINCIPAL_SECRET"),
    "external_viewer_id": os.environ.get("EXTERNAL_VIEWER_ID"),
    "external_value": os.environ.get("EXTERNAL_VALUE"),
    "workspace_id": os.environ.get("WORKSPACE_ID"),
    "port": int(os.environ.get("PORT", 3000)),
}

basic_auth = base64.b64encode(
    f"{CONFIG['service_principal_id']}:{CONFIG['service_principal_secret']}".encode()
).decode()

# -----------------------------------------------------------------------------
# HTTP Request Helper
# -----------------------------------------------------------------------------
def http_request(url, method="GET", headers=None, body=None):
    headers = headers or {}
    if body is not None and not isinstance(body, (bytes, str)):
        raise ValueError("Body must be bytes or str")

    req = urllib.request.Request(url, method=method, headers=headers)
    if body is not None:
        if isinstance(body, str):
            body = body.encode()
        req.data = body

    try:
        with urllib.request.urlopen(req) as resp:
            data = resp.read().decode()
            try:
                return {"data": json.loads(data)}
            except json.JSONDecodeError:
                return {"data": data}
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"HTTP {e.code}: {e.read().decode()}") from None

# -----------------------------------------------------------------------------
# Token logic
# -----------------------------------------------------------------------------
def get_scoped_token():
    # 1. Get all-api token
    oidc_res = http_request(
        f"{CONFIG['instance_url']}/oidc/v1/token?o={CONFIG['workspace_id']}",
        method="POST",
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": f"Basic {basic_auth}",
        },
        body=urllib.parse.urlencode({
            "grant_type": "client_credentials",
            "scope": "all-apis"
        })
    )
    oidc_token = oidc_res["data"]["access_token"]

    # 2. Get token info
    token_info_url = (
        f"{CONFIG['instance_url']}/api/2.0/lakeview/dashboards/"
        f"{CONFIG['dashboard_id']}/published/tokeninfo"
        f"?o={CONFIG['workspace_id']}"
        f"&external_viewer_id={urllib.parse.quote(CONFIG['external_viewer_id'])}"
        f"&external_value={urllib.parse.quote(CONFIG['external_value'])}"
    )
    token_info = http_request(
        token_info_url,
        headers={"Authorization": f"Bearer {oidc_token}"}
    )["data"]

    # 3. Generate scoped token
    params = token_info.copy()
    authorization_details = params.pop("authorization_details", None)
    params.update({
        "grant_type": "client_credentials",
        "authorization_details": json.dumps(authorization_details)
    })

    scoped_res = http_request(
        f"{CONFIG['instance_url']}/oidc/v1/token?o={CONFIG['workspace_id']}",
        method="POST",
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": f"Basic {basic_auth}",
        },
        body=urllib.parse.urlencode(params)
    )
    return scoped_res["data"]["access_token"]

# -----------------------------------------------------------------------------
# HTML generator
# -----------------------------------------------------------------------------
def generate_html(token):
    return f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard Demo</title>
    <style>
        body {{ font-family: system-ui; margin: 0; padding: 20px; background: #f5f5f5; }}
        .container {{ max-width: 1200px; margin: 0 auto; height:calc(100vh - 40px) }}
    </style>
</head>
<body>
    <div id="dashboard-content" class="container"></div>
    <script type="module">
        import {{ DatabricksDashboard }} from "https://cdn.jsdelivr.net/npm/@databricks/aibi-client@0.0.0-alpha.7/+esm";
        const dashboard = new DatabricksDashboard({{
            instanceUrl: "{CONFIG['instance_url']}",
            workspaceId: "{CONFIG['workspace_id']}",
            dashboardId: "{CONFIG['dashboard_id']}",
            token: "{token}",
            container: document.getElementById("dashboard-content")
        }});
        dashboard.initialize();
    </script>
</body>
</html>"""

# -----------------------------------------------------------------------------
# HTTP server
# -----------------------------------------------------------------------------
class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/":
            self.send_response(404)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Not Found")
            return

        try:
            token = get_scoped_token()
            html = generate_html(token)
            status = 200
        except Exception as e:
            html = f"<h1>Error</h1><p>{e}</p>"
            status = 500

        self.send_response(status)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        self.wfile.write(html.encode())

def start_server():
    missing = [k for k, v in CONFIG.items() if not v]
    if missing:
        print(f"Missing: {', '.join(missing)}", file=sys.stderr)
        sys.exit(1)

    server = HTTPServer(("localhost", CONFIG["port"]), RequestHandler)
    print(f":rocket: Server running on http://localhost:{CONFIG['port']}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        sys.exit(0)

if __name__ == "__main__":
    start_server()

JavaScript

Copy and save this in a file named example.js.

#!/usr/bin/env node

const http = require('http');
const https = require('https');
const { URL, URLSearchParams } = require('url');

// This constant is just a mapping of environment variables to their respective
// values.
const CONFIG = {
  instanceUrl: process.env.INSTANCE_URL,
  dashboardId: process.env.DASHBOARD_ID,
  servicePrincipalId: process.env.SERVICE_PRINCIPAL_ID,
  servicePrincipalSecret: process.env.SERVICE_PRINCIPAL_SECRET,
  externalViewerId: process.env.EXTERNAL_VIEWER_ID,
  externalValue: process.env.EXTERNAL_VALUE,
  workspaceId: process.env.WORKSPACE_ID,
  port: process.env.PORT || 3000,
};

const basicAuth = Buffer.from(`${CONFIG.servicePrincipalId}:${CONFIG.servicePrincipalSecret}`).toString('base64');

// ------------------------------------------------------------------------------------------------
// Main
// ------------------------------------------------------------------------------------------------

function startServer() {
  const missing = Object.keys(CONFIG).filter((key) => !CONFIG[key]);
  if (missing.length > 0) throw new Error(`Missing: ${missing.join(', ')}`);

  const server = http.createServer(async (req, res) => {
    // This is a demo server, we only support GET requests to the root URL.
    if (req.method !== 'GET' || req.url !== '/') {
      res.writeHead(404, { 'Content-Type': 'text/plain' });
      res.end('Not Found');
      return;
    }

    let html = '';
    let status = 200;

    try {
      const token = await getScopedToken();
      html = generateHTML(token);
    } catch (error) {
      html = `<h1>Error</h1><p>${error.message}</p>`;
      status = 500;
    } finally {
      res.writeHead(status, { 'Content-Type': 'text/html' });
      res.end(html);
    }
  });

  server.listen(CONFIG.port, () => {
    console.log(`🚀 Server running on http://localhost:${CONFIG.port}`);
  });

  process.on('SIGINT', () => process.exit(0));
  process.on('SIGTERM', () => process.exit(0));
}

async function getScopedToken() {
  // 1. Get all-api token. This will allow you to access the /tokeninfo
  // endpoint, which contains the information required to generate a scoped token
  const {
    data: { access_token: oidcToken },
  } = await httpRequest(`${CONFIG.instanceUrl}/oidc/v1/token?o=${CONFIG.workspaceId}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: `Basic ${basicAuth}`,
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      scope: 'all-apis',
    }),
  });

  // 2. Get token info. This information is **required** for generating a token that is correctly downscoped.
  // A correctly downscoped token will only have access to a handful of APIs, and within those APIs, only
  // a the specific resources required to render the dashboard.
  //
  // This is essential to prevent leaking a privileged token.
  //
  // At the time of writing, OAuth tokens in Databricks are valid for 1 hour.
  const tokenInfoUrl = new URL(
    `${CONFIG.instanceUrl}/api/2.0/lakeview/dashboards/${CONFIG.dashboardId}/published/tokeninfo?o=${CONFIG.workspaceId}`,
  );
  tokenInfoUrl.searchParams.set('external_viewer_id', CONFIG.externalViewerId);
  tokenInfoUrl.searchParams.set('external_value', CONFIG.externalValue);

  const { data: tokenInfo } = await httpRequest(tokenInfoUrl.toString(), {
    headers: { Authorization: `Bearer ${oidcToken}` },
  });

  // 3. Generate scoped token. This call is very similar to what was issued before, but now we are providing the scoping to make the generated token
  // safe to pass to a browser.
  const { authorization_details, ...params } = tokenInfo;
  const {
    data: { access_token },
  } = await httpRequest(`${CONFIG.instanceUrl}/oidc/v1/token?o=${CONFIG.workspaceId}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Authorization: `Basic ${basicAuth}`,
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      ...params,
      authorization_details: JSON.stringify(authorization_details),
    }),
  });

  return access_token;
}

startServer();

// ------------------------------------------------------------------------------------------------
// Helper functions
// ------------------------------------------------------------------------------------------------

/**
 * Helper function to create HTTP requests.
 * @param {string} url - The URL to make the request to.
 * @param {Object} options - The options for the request.
 * @param {string} options.method - The HTTP method to use.
 * @param {Object} options.headers - The headers to include in the request.
 * @param {Object} options.body - The body to include in the request.
 * @returns {Promise<Object>} A promise that resolves to the response data.
 */
function httpRequest(url, { method = 'GET', headers = {}, body } = {}) {
  return new Promise((resolve, reject) => {
    const isHttps = url.startsWith('https://');
    const lib = isHttps ? https : http;
    const options = new URL(url);
    options.method = method;
    options.headers = headers;

    const req = lib.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => (data += chunk));
      res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          try {
            resolve({ data: JSON.parse(data) });
          } catch {
            resolve({ data });
          }
        } else {
          reject(new Error(`HTTP ${res.statusCode}: ${data}`));
        }
      });
    });

    req.on('error', reject);

    if (body) {
      if (typeof body === 'string' || Buffer.isBuffer(body)) {
        req.write(body);
      } else if (body instanceof URLSearchParams) {
        req.write(body.toString());
      } else {
        req.write(JSON.stringify(body));
      }
    }
    req.end();
  });
}

function generateHTML(token) {
  return `<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard Demo</title>
    <style>
        body { font-family: system-ui; margin: 0; padding: 20px; background: #f5f5f5; }
        .container { max-width: 1200px; margin: 0 auto; height:calc(100vh - 40px) }
    </style>
</head>
<body>
    <div id="dashboard-content" class="container"></div>
    <script type="module">
        /**
         * We recommend bundling the dependency instead of using a CDN. However, for demonstration purposes,
         * we are just using a CDN.
         * 
         * We do not recommend one CDN over another and encourage decoupling the dependency from third-party code.
         */
        import { DatabricksDashboard } from "https://cdn.jsdelivr.net/npm/@databricks/aibi-client@0.0.0-alpha.7/+esm";
    
        const dashboard = new DatabricksDashboard({
            instanceUrl: "${CONFIG.instanceUrl}",
            workspaceId: "${CONFIG.workspaceId}",
            dashboardId: "${CONFIG.dashboardId}",
            token: "${token}",
            container: document.getElementById("dashboard-content")
        });
        
        dashboard.initialize();
    </script>
</body>
</html>`;
}

Step 5: Run the example application

Replace the following values and then run the code block from your terminal. Your values should not be surrounded by angle brackets (< >):

  • Use the workspace URL to find and replace the following values:
    • <your-instance>
    • <workspace_id>
    • <dashboard_id>
  • Replace the following values with the values you created when creating the service principal (step 2):
    • <service_principal_id>
    • <service_principal_secret> (client secret)
  • Replace the following values with identifiers associated with users of the external application:
    • <some-external-viewer>
    • <some-external-value>
  • Replace </path/to/example> with the path to the .py or .js file you created in the previous step. Include the file extension.

Note

Do not include any personally-identifiable information (PII) in the EXTERNAL_VIEWER_ID value.


INSTANCE_URL='https://<your-instance>.databricks.com' \
WORKSPACE_ID='<workspace_id>' \
DASHBOARD_ID='<dashboard_id>' \
SERVICE_PRINCIPAL_ID='<service-principal-id>' \
SERVICE_PRINCIPAL_SECRET='<service-principal_secret>' \
EXTERNAL_VIEWER_ID='<some-external-viewer>' \
EXTERNAL_VALUE='<some-external-value>' \
~</path/to/example>

# Terminal will output: :rocket: Server running on http://localhost:3000

Configuration options

When you initialize DatabricksDashboard, you can pass a config object to customize the appearance and behavior of the embedded dashboard. The config object requires a version field set to 1.

By default, embedded dashboards display a Powered by Databricks logo in the footer. To hide the logo, set hideDatabricksLogo: true in the config object.

const dashboard = new DatabricksDashboard({
  instanceUrl: '<your-instance>.databricks.com',
  workspaceId: '<workspace_id>',
  dashboardId: '<dashboard_id>',
  token: '<scoped-token>',
  container: document.getElementById('dashboard-content'),
  config: {
    version: 1,
    hideDatabricksLogo: true,
  },
});
dashboard.initialize();

Data downloads in external embedding

By default, viewers of embedded dashboards can download data as CSV, TSV, or Excel files, and download visualizations as PNG files. To disable data downloads across your workspace, a workspace admin can turn off the download setting in the workspace admin console.

Control dashboard filters programmatically

In external embedding, you can read and change a dashboard's filter values from your application code. The DatabricksDashboard object provides methods to read the current filter state, set new values, and subscribe to filter changes. Use these methods to sync embedded filters with your own controls, set filters from application state, or update your interface when a viewer changes a filter.

The filter methods are available after the embedded dashboard's controls are ready. Databricks buffers calls made before then and applies them when the controls become available.

Identify a filter

Each filter has an id of the form <pageId>~<widgetId> that stays the same across dashboard edits. To find the IDs for a dashboard, call getFilters and match on each filter's displayName. Use the id as the durable handle in your code, because displayName is author-controlled and can change or repeat.

A dashboard author can change a filter's ID in the dashboard editor by selecting the widget or page and using the Update URL identifier action.

Read filter state

Call getFilters to read the current state of every filter in the dashboard. It returns an array of filter states, each with the following fields:

Field Description
id The filter's URL identifier, <pageId>~<widgetId>.
widgetType The filter control type: single-select, multi-select, text, range, date-picker, or date-range.
displayName The filter's title, falling back to its field name.
isDefault true when the filter shows its author-configured default value.
value The current filter value, or undefined when the filter is unset or its value can't be represented.
unsupportedValue true when the filter holds a value that can't be represented. Don't set values on these filters.
const filters = await dashboard.getFilters();
const filtersById = Object.fromEntries(filters.map((f) => [f.id, f]));
const regionFilter = filtersById['sales~region'];

Set filter values

Call setFilters with an array of filter values to apply. Each value targets a filter by its id and matches the filter's type.

await dashboard.setFilters([{ id: 'sales~region', type: 'single-select', value: 'US' }]);

The value fields depend on the filter type:

Type Value fields
single-select value: a single option string. Optional exclude: true matches anything except the value.
multi-select values: an array of option strings. Optional exclude: true.
text value: the search text.
range min and max: numbers.
date-picker value: an ISO date or datetime string.
date-range min and max: ISO date or datetime strings.

To reset or clear a filter, use one of the following built-in states in place of a value:

  • Default: { default: true } resets the filter to its author-configured default. An empty string or empty array does the same.
  • All: { all: true } selects every value. On a range or date-range endpoint, it makes that end unbounded, so min: { all: true }, max: 10 means "less than or equal to 10".
  • None: { none: true } filters everything out. Available on single-select and multi-select filters.

For dashboards in apply mode, where selections are staged until the viewer clicks Apply, setFilters commits values immediately by default. To stage values instead, pass { apply: false }, then commit them later with applyStagedFilters or discard them with cancelStagedFilters.

await dashboard.setFilters([{ id: 'sales~region', type: 'single-select', value: 'US' }], {
  apply: false,
});
await dashboard.applyStagedFilters();

React to filter changes

Call onFiltersChanged to run a callback whenever a filter's value changes, whether from viewer interaction, an applied setFilters call, or a URL restore. The callback receives a snapshot of every filter's current state. It doesn't fire when you subscribe, so read the initial state with getFilters. onFiltersChanged returns a function that removes the subscription.

const unsubscribe = dashboard.onFiltersChanged((filters) => {
  console.log('filters changed', filters);
});
// Later, to stop listening:
unsubscribe();

To run code after the filter controls first become available, call onFiltersReady. It fires once with the initial filter snapshot and returns a function that removes the subscription.