Data Sandboxing - Restricting Row-Level Access by User
Data sandboxing in Metabase is a row-level security mechanism that automatically restricts which rows of a database table a user can query, based on a...
Data Sandboxing: Restricting Row-Level Access by User
Data sandboxing in Metabase is a row-level security mechanism that automatically restricts which rows of a database table a user can query, based on attributes associated with their Metabase account. When a sandbox policy is applied to a table, Metabase wraps every query against that table in a subquery containing a user-specific WHERE clause — regardless of how the query is constructed, which dashboard it comes from, or whether the user writes raw SQL. Sandboxing is available in Metabase Pro and Enterprise.
---
How Sandboxing Works Under the Hood
When a user runs any query against a sandboxed table, Metabase intercepts it and rewrites it:
What the user's question generates:
sql
SELECT product_id, SUM(amount) as revenue FROM orders WHERE created_at > '2024-01-01' GROUP BY product_id ORDER BY revenue DESC
What Metabase actually executes:
sql
SELECT product_id, SUM(amount) as revenue FROM ( SELECT * FROM orders WHERE organization_id = '99' ) AS sandboxed_orders WHERE created_at > '2024-01-01' GROUP BY product_id ORDER BY revenue DESC
The user sees the same query interface and the same results format. The sandbox filter is invisible to them. Their own WHERE clause is preserved — the sandbox simply adds an additional outer filter that cannot be bypassed.
Why This Is More Secure Than Dashboard Filters
Dashboard-level filters (locked parameters in signed embedding) are configured per dashboard. A user who accesses a different dashboard, or uses the query builder directly, may be able to bypass them.
Sandboxing operates at the query execution layer. It applies to:
- Every dashboard question
SELECT * FROM orders returns only sandboxed rows)The only way for a sandboxed user to see rows outside their sandbox is if the sandbox policy itself is misconfigured.
---
Prerequisites
---
User Attributes
User attributes are key-value pairs associated with a Metabase user that sandbox policies reference. They are the bridge between "who is this user" and "what data can they see."
Setting Attributes via JWT SSO (Most Common for Embedded)
javascript
const ssoPayload = { email: user.email, first_name: user.firstName, last_name: user.lastName, groups: ["Customer Portal"], // User attributes — must be strings organization_id: String(user.organizationId), plan_tier: user.planTier, region: user.region, exp: Math.round(Date.now() / 1000) + 600, };
All attribute values must be strings in JWT SSO tokens, even if the column they map to stores integers.
Setting Attributes via SAML
Configure attribute mappings in your IdP to pass custom attributes in SAML assertions. In Metabase SAML settings, map the assertion attribute names to Metabase user attribute names.
Setting Attributes Manually (Admin UI)
For individual users: Admin → People → [User] → User attributes
This is impractical at scale but useful for testing.
Setting Attributes via API
javascript
await fetch(${METABASE_URL}/api/user/${userId}, { method: "PUT", headers: { "x-api-key": API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ login_attributes: { organization_id: "99", plan_tier: "enterprise", }, }), });
---
Sandbox Types
Type 1: Filter by Column
The simplest sandbox. Metabase adds WHERE column = user_attribute_value to every query against the table.
Configuration:
- Column: the column to filter on (e.g., organization_id) - User attribute: the user attribute whose value will be used (e.g., organization_id)
Result: Every query against this table for users in the group automatically includes:
sql
WHERE organization_id = '[user's organization_id attribute]'
When to use it: When every row in the table has a direct column that identifies the tenant/user, and access is simply "show this user only their rows."
Type 2: Custom Sandbox Question
A custom sandbox question is a SQL query you write that defines what rows a user is allowed to see. It can be arbitrarily complex — joins, subqueries, CTEs, and multiple conditions. The query is parameterized with user attributes using {{attribute_name}} syntax.
Configuration:
{{attribute_name}}Example: Access through team membership
A user should only see orders created by members of their team:
sql
SELECT o.* FROM orders o INNER JOIN team_members tm ON o.created_by_user_id = tm.user_id WHERE tm.team_id = {{team_id}} AND tm.status = 'active'
Example: Multi-condition access control
A user can see orders from their organization, but only orders in a specific status tier based on their plan:
sql
SELECT * FROM orders WHERE organization_id = {{organization_id}} AND ( {{plan_tier}} = 'enterprise' OR status NOT IN ('cancelled', 'refunded') )
Example: Hierarchical access
Regional managers see all orders in their region; account managers see only their assigned accounts:
sql
SELECT o.* FROM orders o WHERE CASE WHEN {{role}} = 'regional_manager' THEN o.region = {{region}} WHEN {{role}} = 'account_manager' THEN o.account_id = {{account_id}} ELSE FALSE END
When to use it: When access logic is more complex than a single column equality check — joins are required, multiple attributes are needed, or the access rules differ by user role.
---
Sandboxing Multiple Tables
A realistic multi-tenant data model requires sandboxing every table that contains tenant-specific data. Configure sandbox policies for each table independently:
orders table → Sandboxed: organization_id = {{organization_id}}
customers table → Sandboxed: organization_id = {{organization_id}} invoices table → Sandboxed: organization_id = {{organization_id}} events table → Sandboxed: organization_id = {{organization_id}} products table → Unrestricted (shared reference data) regions table → Unrestricted (shared reference data) audit_log table → Sandboxed: custom query with role check
Reference/lookup tables shared across all tenants (product catalogs, country codes, status enums) typically don't need sandboxing.
Verify completeness: After configuring sandbox policies, log in as a sandboxed test user and browse the database in Metabase's data browser. Every table with per-tenant data should show only the sandboxed user's rows.
---
Sandbox Policies and Joins
When a sandboxed user queries a table that joins to another sandboxed table, Metabase applies the sandbox policy to the base table in the query. The join target table is not independently sandboxed unless the user queries it directly.
For complex data models where joins could expose cross-tenant data, use custom sandbox questions that include the join in the sandbox definition:
sql
-- Sandbox for the orders table that includes a safe join to customers SELECT o.*, c.name as customer_name FROM orders o LEFT JOIN customers c ON o.customer_id = c.id AND c.organization_id = {{organization_id}} -- join condition enforces isolation WHERE o.organization_id = {{organization_id}}
---
Testing Sandbox Policies
Create a Test User
Create a test user in the sandboxed group with known attribute values:
javascript
// Create test user with specific organization_id await fetch(${METABASE_URL}/api/user, { method: "POST", headers: { "x-api-key": API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ first_name: "Test", last_name: "Sandbox", email: "sandbox-test@yourcompany.com", password: generatePassword(), login_attributes: { organization_id: "99", }, }), });
Verify Data Isolation
Log in as the test user (use a private browser window) and:
SELECT * FROM orders in the SQL editor — verify only rows for organization 99 are returnedOR organization_id = '1' to the SQL — verify this doesn't return other org's data (the sandbox wraps the query)Verify SQL Editor Isolation
The most important test is confirming the SQL editor respects sandboxing:
sql
-- This query, run as a sandboxed user, should only return org 99's data -- even though there's no WHERE clause restricting org SELECT * FROM orders LIMIT 100;
-- This attempted bypass should NOT return data for organization 1 SELECT * FROM orders WHERE organization_id = '1' LIMIT 100;
---
Performance Considerations
Sandbox queries wrap every query in a subquery. The performance impact depends on:
Type 1 (column filter): Minimal overhead. The outer WHERE clause is pushed down to the inner query by most query planners. Ensure the sandbox column (organization_id) is indexed.
Type 2 (custom question): Depends on the complexity of the sandbox query. A sandbox that includes joins or complex conditions runs for every query against the table. Test performance with production-sized data.
Index recommendations for sandboxed columns:
sql
-- PostgreSQL: index the tenant identifier on every sandboxed table CREATE INDEX CONCURRENTLY idx_orders_org ON orders(organization_id); CREATE INDEX CONCURRENTLY idx_customers_org ON customers(organization_id); CREATE INDEX CONCURRENTLY idx_invoices_org ON invoices(organization_id);
---
Common Issues
"User attribute not found" error The attribute referenced in the sandbox policy ({{organization_id}}) doesn't exist on the current user. Verify the attribute is set in the user's profile or included in the JWT SSO token.
Sandbox returns zero rows for valid users The attribute value type may not match the column type. If organization_id is an integer in the database but the user attribute is stored as string "99", Metabase should coerce it — but test explicitly. Also check that the user's attribute value actually exists as a value in the column.
Users in the Administrators group bypass sandboxes This is by design. Administrators bypass all permission restrictions including sandboxes. Don't add users who should be sandboxed to the Administrators group.
Sandbox not applied to a specific table Check that the group's database access is set to "Granular" (not "Unrestricted") and that the specific table's access is set to "Sandboxed." Unrestricted database access at the group level bypasses table-level sandboxes.
---
Summary
Data sandboxing enforces row-level access control at query execution time by wrapping every query against a sandboxed table with a user-specific filter. It applies to all query paths — dashboards, query builder, and SQL editor — making it more robust than dashboard-level filter locking for use cases where users have broad Metabase access. Configure sandbox policies per table for every table containing tenant-specific data. Use column-filter sandboxes for simple single-column isolation; use custom sandbox questions for complex access logic involving joins or multiple conditions. Always test with a non-admin user in the sandboxed group to verify isolation works end-to-end.