Security & Auth

Row-Level Security for Embedded Dashboards

Row-level security (RLS) in Metabase — implemented through a feature called data sandboxing — restricts which rows of a database table a given user ca...

📅
📖9 min read

Row-Level Security for Embedded Dashboards

Row-level security (RLS) in Metabase — implemented through a feature called data sandboxing — restricts which rows of a database table a given user can query, regardless of which dashboard or question they're using. Instead of filtering data at the dashboard level (which can be bypassed if a user has access to the query builder), sandboxing enforces access control at the query execution layer: Metabase automatically appends a WHERE clause to every query against a sandboxed table based on the current user's attributes.

Row-level security is the technically correct approach to data isolation in embedded analytics. It is available in Metabase Pro and Enterprise.

---

Why Row-Level Security Matters for Embedded Analytics

Without row-level security, data access in embedded Metabase is controlled only by:

  • Which dashboards a user can see (permission groups)
  • Which parameters are locked in the embed token (for signed embedding)
  • This is sufficient for simple use cases — a dashboard that's pre-filtered to a single customer via a locked parameter works fine. But it breaks down when:

  • Users have access to Metabase's native query builder or SQL editor
  • A configuration mistake leaves a filter unlocked
  • The same table is used in multiple dashboards with inconsistent filtering
  • Access logic is more complex than a single column match
  • Row-level security closes these gaps. Even if a user somehow accesses a dashboard without the intended filter, or runs an ad-hoc SQL query, they can only see rows they're authorized to see. The restriction is enforced at query execution time, not at the dashboard configuration level.

    ---

    How Metabase Data Sandboxing Works

    When you configure a sandbox policy on a table, Metabase intercepts queries against that table and wraps them:

    sql
    

    -- What the user's question generates: SELECT order_date, SUM(amount) as revenue FROM orders GROUP BY order_date

    -- What Metabase actually executes against the database: SELECT order_date, SUM(amount) as revenue FROM ( SELECT * FROM orders WHERE organization_id = 99 ) AS sandboxed_orders GROUP BY order_date

    The wrapping is transparent to the user — they see the same query interface, but the results are automatically restricted to their allowed rows.

    ---

    Setting Up Data Sandboxing

    Prerequisites

  • Metabase Pro or Enterprise
  • User attributes configured for the users you want to sandbox
  • A Metabase group for sandboxed users
  • Step 1: Create a Group for Sandboxed Users

    In Admin → People → Groups, create a group (e.g., Customer Portal Users or Embedded Viewers). Users who should be sandboxed belong to this group.

    Step 2: Assign User Attributes

    User attributes are key-value pairs that Metabase uses to evaluate sandbox policies. For JWT-authenticated embedded users, attributes are passed in the JWT SSO token.

    For JWT SSO (embedded users):

    javascript
    

    const ssoPayload = { email: user.email, first_name: user.firstName, last_name: user.lastName, groups: ["Customer Portal Users"], // Attributes used in sandbox policies: organization_id: String(user.organizationId), role: user.role, exp: Math.round(Date.now() / 1000) + 600 };

    const ssoToken = jwt.sign(ssoPayload, METABASE_JWT_SHARED_SECRET); // Redirect: /auth/sso?jwt=<token>

    Important: All attribute values in the JWT must be strings, even if the column they map to is an integer. Metabase handles type coercion.

    For SAML users: Attributes are passed as SAML assertions. Configure attribute mapping in Admin → Settings → Authentication → SAML.

    For manually managed users: Set attributes in Admin → People → [User] → User attributes.

    Step 3: Configure Permissions

    In Admin → Permissions → Data:

  • Select your database
  • Find the group (Customer Portal Users)
  • For each table you want to sandbox, change the access from Unrestricted to Sandboxed
  • Step 4: Configure the Sandbox Policy

    After setting a table to Sandboxed, Metabase prompts you to configure the sandbox policy. You have two options:

    ---

    #### Option A: Filter by Column (Simple)

    The simplest sandbox: filter the table where column = user_attribute.

    Configuration:

  • Filter column: organization_id
  • User attribute: organization_id
  • Effect: Every query against this table automatically includes WHERE organization_id = [user's organization_id attribute].

    This works for the most common multi-tenant pattern where each row belongs to one tenant and the tenant identifier is a direct column on the table.

    ---

    #### Option B: Custom Sandbox Question (Advanced)

    For complex access logic, define a SQL query that returns the rows a user is allowed to see. The custom question is parameterized with user attributes using {{attribute_name}} syntax.

    Example: A user can only see orders that belong to their organization AND were created by team members they manage:

    sql
    

    SELECT o.* FROM orders o JOIN organization_members om ON o.created_by = om.user_id AND o.organization_id = om.organization_id WHERE om.manager_id = {{current_user_id}} AND o.organization_id = {{organization_id}}

    The {{current_user_id}} and {{organization_id}} are substituted with the current user's attributes at query time.

    Custom sandbox questions can reference other sandboxed tables (Metabase applies the outer sandbox policy), and they can implement arbitrarily complex logic — joins, subqueries, CTEs.

    ---

    Sandboxing Multiple Tables

    A realistic data model has multiple related tables. You need to configure sandbox policies for every table that contains tenant-specific data:

    TableSandbox TypeFilter
    ordersColumn filterorganization_id = {{organization_id}}
    customersColumn filterorganization_id = {{organization_id}}
    invoicesColumn filterorganization_id = {{organization_id}}
    audit_logCustom questionComplex join to verify org membership
    productsNone (shared across tenants)No sandbox needed
    Reference (lookup) tables that are shared across tenants — product catalogs, status codes, country lists — typically don't need sandboxing.

    ---

    Row-Level Security vs. Locked Parameters

    Both approaches restrict what data a user sees. Understanding the difference is important:

    DimensionLocked ParametersData Sandboxing (RLS)
    Enforcement layerDashboard configurationQuery execution
    Applies to SQL editorNoYes
    Applies to ad-hoc questionsNoYes
    Works if filter is accidentally removedNoYes
    Setup complexityLowMedium
    Requires Pro/EnterpriseYes (for locked params)Yes
    Can express complex logicLimitedYes (custom sandbox)
    The key difference: Locked parameters only apply to questions on dashboards where the parameter is configured. Data sandboxing applies to every query against the sandboxed table, everywhere in Metabase, regardless of how the query is constructed.

    For embedded analytics where users only see pre-built dashboards and have no access to the query builder, locked parameters are often sufficient. For deployments where users have broader Metabase access, data sandboxing is the more robust choice.

    ---

    Combining Both Approaches

    The most secure configuration uses both locked parameters and data sandboxing together:

  • Locked parameters ensure the dashboard renders filtered data immediately, without requiring Metabase to look up user attributes
  • Data sandboxing acts as a defense-in-depth measure — even if the locked parameter is somehow bypassed or misconfigured, the sandbox policy enforces the restriction at the database level
  • javascript
    

    // JWT for embedded user with both locked param and sandbox attributes const ssoPayload = { email: user.email, groups: ["Customer Portal Users"], organization_id: String(user.organizationId), // used by sandbox policy exp: Math.round(Date.now() / 1000) + 600 };

    // Separate embedding token const embedPayload = { resource: { dashboard: DASHBOARD_ID }, params: { organization_id: user.organizationId // locked filter on the dashboard }, exp: Math.round(Date.now() / 1000) + 600 };

    ---

    Testing Row-Level Security

    After configuring sandboxing, verify it's working correctly:

    1. Test With a Sandboxed User Account

    Create a test user account in the sandboxed group with known attribute values. Log in as this user and:

  • Open the sandboxed table in the query builder
  • Run SELECT * FROM orders LIMIT 100
  • Verify only rows matching the user's organization_id are returned
  • 2. Verify the SQL Editor Is Restricted

    If the sandboxed user has access to the native SQL editor:

  • Run SELECT * FROM orders WHERE organization_id =
  • Verify the results are empty (sandbox policy ignores the user's WHERE clause and replaces it with the sandbox filter)
  • 3. Test Attribute Injection

    Run a query and check Metabase's query logs to confirm the sandbox filter is being applied. In Admin → Troubleshooting → Logs, look for the actual SQL being executed against your database — it should include the sandbox WHERE clause.

    4. Test Edge Cases

  • What happens when a user's attribute is null? (Usually returns no rows — verify this is the intended behavior)
  • What happens when the attribute value doesn't match any rows? (Should return empty results, not an error)
  • Does the sandbox apply when using Metabase's data export feature?
  • ---

    Performance Considerations

    Data sandboxing wraps queries in a subquery, which has minor performance implications:

    For column filter sandboxing, the overhead is minimal. The WHERE clause on the inner query is semantically equivalent to adding it to the outer query, and most database query planners optimize this correctly.

    For custom sandbox questions, performance depends on the complexity of the custom query. Complex joins or subqueries in the sandbox definition run for every query against the sandboxed table. Ensure the sandbox query itself is well-optimized with appropriate indexes.

    Monitor slow query logs after enabling sandboxing to identify any performance regressions.

    ---

    Common Issues

    "Sandbox attribute not found" The user attribute referenced in the sandbox policy ({{organization_id}}) doesn't exist on the current user. Verify the attribute is set on the user's profile or included in the JWT SSO token.

    Users see all rows despite sandboxing Check that the user is actually in the sandboxed group. Users in groups with Unrestricted access to the database bypass sandbox policies.

    Sandbox returns no results for valid users The attribute value type may not match the column type. Ensure numeric IDs are passed as strings in JWT attributes ("99" not 99) and that Metabase is converting them correctly.

    Custom sandbox query fails Test the custom sandbox query directly against your database, substituting real attribute values for the {{variable}} placeholders. Verify it returns the expected rows.

    ---

    Summary

    Row-level security in Metabase — implemented as data sandboxing — restricts query results at the execution layer by wrapping every query against a sandboxed table with a user-specific WHERE clause. It is enforced regardless of how the query is constructed, making it more robust than dashboard-level filter locking for deployments where users have broad Metabase access. For simple multi-tenant embedded dashboards, locked parameters may be sufficient; for any deployment involving the SQL editor or ad-hoc querying, data sandboxing is the correct approach.