Embedding Analytics

Multi-Tenant Embedded Analytics - Showing Each Customer Their Own Data

Multi-tenant embedded analytics is the pattern of embedding a single Metabase dashboard into a SaaS product such that each customer sees only their ow...

šŸ“…
šŸ“–10 min read

Multi-Tenant Embedded Analytics: Showing Each Customer Their Own Data

Multi-tenant embedded analytics is the pattern of embedding a single Metabase dashboard into a SaaS product such that each customer sees only their own data — not data belonging to other customers — without maintaining separate dashboards, databases, or Metabase instances per customer. It is the most common production use case for Metabase embedding, and it is solved through a combination of signed JWT tokens and Metabase's data sandboxing (row-level security) feature.

Getting multi-tenancy right is the difference between a secure embedded analytics feature and a data breach. This guide covers the two primary implementation approaches, their tradeoffs, and the specific Metabase configuration required for each.

---

The Core Problem

Imagine you're building a SaaS product where each customer has their own organization. Your database has an orders table with an organization_id column. You want to show each customer a dashboard of their orders.

The naive approach — one dashboard per customer — doesn't scale. With 100 customers you'd need 100 dashboards. With 1,000, the management overhead is unmanageable. The solution is to build one dashboard that dynamically filters to the current customer's data at query time.

The security requirement is strict: customer A must not be able to see customer B's data, even by manipulating the URL or the iframe. This requires server-side enforcement — browser-side filtering is not sufficient.

---

Approach 1: Locked Parameters (Simpler, Recommended Starting Point)

The simplest multi-tenancy approach uses Metabase's locked parameter feature. You create a dashboard with a filter on organization_id, mark that filter as Locked in the embedding configuration, and pass the current user's organization_id in the JWT token.

How It Works

User loads analytics page

│ ā–¼ Your server authenticates the user │ ā–¼ Your server reads user.organizationId │ ā–¼ Your server generates JWT: { resource: { dashboard: 42 }, params: { organization_id: user.organizationId }, exp: now + 600 } │ ā–¼ Browser renders iframe with signed URL │ ā–¼ Metabase validates JWT signature │ ā–¼ Metabase applies organization_id filter to ALL queries │ ā–¼ Dashboard only shows data for that organization

Because the organization_id is locked in the JWT payload (which is signed server-side), users cannot change it. Even if they inspect the iframe URL or attempt to modify it, the signature verification will fail.

Setting Up Locked Parameters

Step 1: Add an Organization ID filter to your dashboard

In Metabase dashboard editor:

  • Click Add a filter → choose ID filter type
  • Name it Organization ID
  • Connect it to the organization_id column in each question on the dashboard
  • Step 2: Configure the filter as Locked in embedding settings

    In Sharing → Embed this dashboard:

  • Set Organization ID to Locked
  • Step 3: Pass the value in the JWT

    javascript
    

    const payload = { resource: { dashboard: DASHBOARD_ID }, params: { organization_id: currentUser.organizationId // locked — enforced server-side }, exp: Math.round(Date.now() / 1000) + 600 };

    Limitations of Locked Parameters

    This approach works well when:

  • All questions in the dashboard filter on the same column (organization_id)
  • The column exists directly in the tables being queried
  • You have a small number of tenant-identifying dimensions
  • It becomes cumbersome when:

  • Some tables join through multiple hops to reach organization_id
  • Different questions in the dashboard use different tenant-identifying columns
  • You need complex tenant isolation logic beyond a simple column match
  • For these cases, data sandboxing (Approach 2) provides more robust isolation.

    ---

    Approach 2: Data Sandboxing (Row-Level Security)

    Data sandboxing — available in Metabase Pro and Enterprise — enforces row-level access control at the database table level rather than at the dashboard filter level. Instead of filtering at query time via a parameter, Metabase automatically appends a WHERE clause to every query against a sandboxed table, based on the user's attributes.

    This approach is more powerful because:

  • It works regardless of how the dashboard is configured
  • It applies to every query, including ad-hoc SQL in the native editor
  • It can handle complex isolation logic through custom sandbox queries
  • It protects against configuration mistakes that might leave a dashboard un-filtered
  • How Data Sandboxing Works

  • You define a sandbox policy on a table: "when a user in group X queries this table, filter by organization_id = {{user.organization_id}}"
  • When a user runs any query against that table, Metabase automatically wraps it: SELECT FROM (SELECT FROM orders WHERE organization_id = 99) AS sandboxed_orders
  • The user — and the dashboard — can never see rows outside their allowed scope
  • Setting Up Data Sandboxing

    Step 1: Create a Metabase group for sandboxed users

    In Admin → People → Groups, create a group called Embedded Users (or similar).

    Step 2: Set user attributes on the group or individual user

    User attributes are key-value pairs attached to a Metabase user that can be referenced in sandbox policies.

    For JWT-authenticated embedded users, set attributes in the JWT:

    javascript
    

    const payload = { resource: { dashboard: DASHBOARD_ID }, params: {}, exp: Math.round(Date.now() / 1000) + 600 };

    Wait — for data sandboxing with embedded users, you use JWT SSO (not just embedding tokens) to pass user attributes. This requires configuring JWT SSO in Metabase Admin:

    javascript
    

    // JWT SSO token for embedded user (different from embedding token) const ssoPayload = { email: currentUser.email, first_name: currentUser.firstName, last_name: currentUser.lastName, groups: ["Embedded Users"], // User attributes for sandbox policies: organization_id: String(currentUser.organizationId), exp: Math.round(Date.now() / 1000) + 600 };

    Step 3: Configure the sandbox policy

    In Admin → Permissions → Data → [Your Database]:

  • Select the group (Embedded Users)
  • Set access to the table as Sandboxed
  • Choose sandbox type:
  • - Filter by a column in the table — simplest option; filters WHERE column = user_attribute - Use a custom question — more powerful; lets you write a SQL query that determines what the user can see

    Simple column filter sandbox:

  • Table: orders
  • Filter column: organization_id
  • User attribute: organization_id
  • Result: Every query against orders by a user in Embedded Users automatically includes WHERE organization_id = [user's organization_id].

    Custom question sandbox:

    sql
    

    -- Custom sandbox query for the orders table SELECT o.* FROM orders o JOIN organization_members om ON o.organization_id = om.organization_id WHERE om.user_id = {{user_id}} AND om.status = 'active'

    This approach lets you implement arbitrarily complex access logic — not just simple column equality.

    ---

    Choosing Between Approaches

    FactorLocked ParametersData Sandboxing
    Plan requirementProPro
    Setup complexityLowMedium
    SQL editor accessNot restricted by defaultFully enforced
    Works with ad-hoc queriesNo (dashboard-specific)Yes
    Complex access logicLimitedYes (custom sandbox queries)
    Multiple tenant columnsRequires multiple paramsHandled in sandbox query
    Performance overheadMinimalMinimal (query wrapping)
    Best forSimple, dashboard-only embeddingFull-featured embedded analytics with SQL access
    For most SaaS products embedding a fixed dashboard for customers, locked parameters are sufficient and simpler to implement. Choose data sandboxing if you're giving users access to the query builder or SQL editor, or if your access logic is more complex than a single column equality check.

    ---

    Scaling Multi-Tenant Deployments

    One Metabase Instance for All Tenants

    The standard architecture is a single Metabase instance serving all tenants. This works because data isolation is enforced at query time — Metabase doesn't need separate instances per tenant. One instance with proper sandboxing can safely serve thousands of tenants.

    When to Consider Multiple Instances

    Very large deployments with strict compliance requirements (e.g., regulated industries where tenant data must be physically isolated) sometimes run separate Metabase instances per customer or customer tier. This is unusual and adds significant operational overhead — avoid it unless required.

    Performance at Scale

    As the number of concurrent embedded dashboard viewers grows, the primary bottleneck shifts from Metabase to the underlying database. Embedded dashboards run real queries against your database — 1,000 concurrent viewers means 1,000+ active queries.

    Mitigation strategies:

  • Query caching in Metabase — cache results for dashboards where real-time freshness isn't required
  • Read replicas — direct Metabase to a read replica rather than the primary database
  • Pre-aggregated data — for high-cardinality queries, pre-aggregate in dbt or a materialized view and have Metabase query the aggregated table
  • ---

    Testing Multi-Tenant Isolation

    Before shipping embedded analytics to production, verify that tenant isolation is working correctly:

    Manual Testing

  • Create two test users belonging to different organizations
  • Generate embed URLs for each user
  • Verify each dashboard only shows data for the correct organization
  • Attempt to modify the organization_id parameter in the URL — it should be rejected because the parameter is locked
  • Automated Testing

    javascript
    

    // Example test: verify embed URL for org 1 doesn't expose org 2 data describe("Multi-tenant analytics isolation", () => { it("generates different embed URLs for different organizations", () => { const url1 = getDashboardEmbedUrl(DASHBOARD_ID, { organization_id: 1 }); const url2 = getDashboardEmbedUrl(DASHBOARD_ID, { organization_id: 2 });

    // URLs should be different (different JWT payloads = different tokens) expect(url1).not.toEqual(url2);

    // Tokens should not be valid cross-tenant (can't test without Metabase API) const token1 = url1.split("/embed/dashboard/")[1].split("#")[0]; const token2 = url2.split("/embed/dashboard/")[1].split("#")[0]; expect(token1).not.toEqual(token2); });

    it("never exposes the secret key in the embed URL", () => { const url = getDashboardEmbedUrl(DASHBOARD_ID, { organization_id: 1 }); expect(url).not.toContain(process.env.METABASE_SECRET_KEY); }); });

    ---

    Common Mistakes

    Generating embed URLs client-side If the secret key is in the browser, any user can generate arbitrary tokens for any organization_id. Always generate tokens on the server.

    Not testing with production-like data volumes Multi-tenant queries that are fast with small datasets can be slow with real customer data sizes. Test with representative data before launch.

    Forgetting to configure sandbox on new tables If you add a new table to your database and it's not sandboxed, users may be able to access it via the query builder without restrictions. Audit new tables and apply sandbox policies proactively.

    Using the same dashboard ID in the client for display purposes The dashboard ID in the embed URL is visible in the browser (it's part of the JWT payload, which is base64-encoded). Don't use it as a security mechanism — rely on the signature and locked parameters, not obscurity.

    ---

    Summary

    Multi-tenant embedded analytics in Metabase is implemented through locked JWT parameters (for dashboard-level isolation) or data sandboxing (for table-level, query-wide isolation). Both approaches enforce data isolation server-side — users cannot override the tenant filter. A single Metabase instance can safely serve thousands of tenants with proper configuration. For most SaaS products, locked parameters provide sufficient isolation with minimal setup; data sandboxing is the right choice when users need access to the query builder or when access logic is complex.