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...
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
Organization IDorganization_id column in each question on the dashboardStep 2: Configure the filter as Locked in embedding settings
In Sharing ā Embed this dashboard:
Organization ID to LockedStep 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:
organization_id)It becomes cumbersome when:
organization_idFor 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:
How Data Sandboxing Works
organization_id = {{user.organization_id}}"SELECT FROM (SELECT FROM orders WHERE organization_id = 99) AS sandboxed_ordersSetting 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]:
Embedded Users)- 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:
ordersorganization_idorganization_idResult: 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
| Factor | Locked Parameters | Data Sandboxing |
|---|---|---|
| Plan requirement | Pro | Pro |
| Setup complexity | Low | Medium |
| SQL editor access | Not restricted by default | Fully enforced |
| Works with ad-hoc queries | No (dashboard-specific) | Yes |
| Complex access logic | Limited | Yes (custom sandbox queries) |
| Multiple tenant columns | Requires multiple params | Handled in sandbox query |
| Performance overhead | Minimal | Minimal (query wrapping) |
| Best for | Simple, dashboard-only embedding | Full-featured embedded analytics with SQL access |
---
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:
---
Testing Multi-Tenant Isolation
Before shipping embedded analytics to production, verify that tenant isolation is working correctly:
Manual Testing
organization_id parameter in the URL ā it should be rejected because the parameter is lockedAutomated 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.