See all articles

Multi-tenant embedded analytics: show each customer only its own data

In multi-tenant embedded analytics, your server signs the signed-in user’s tenant id into the embed token, and Metabase filters the data with it. Guest embeds do this on every edition, including the free open-source edition, with a locked parameter that only your server can set. On Pro and Enterprise, a Modular embed passes the tenant id as a user attribute, and row and column security shows each customer only the rows that match it.

Your app already decides which customer each signed-in user belongs to, so Metabase doesn’t need a second copy of that rule. The tenant id arrives in a token that your server signs, which means the browser can’t change it. This guide sets up both kinds of embed, with server code for Next.js, Django and Rails.

Guest embedding or Modular embedding: which one fits a multi-tenant app?

Guest embeds work on every Metabase edition, including open source. Viewers don’t need a Metabase account, and they don’t count as billed users on any plan. On the open-source and Starter plans, guest embeds show a “Powered by Metabase” banner, and removing it needs Pro or Enterprise.

Modular embeds sign each viewer in through single sign-on, here with JWT, and they’re part of the Pro and Enterprise plans. Metabase creates an account for each viewer the first time your app signs them in, and each account counts as a user on your bill. Because Metabase knows who is looking, the same tenant rule covers drill-through and every question a customer builds in the query builder.

You can use only one of the two methods on a given page of your app. Metabase’s data isolation guide recommends locked parameters when you want to filter data without Metabase accounts. For sensitive data, it recommends a method that enforces access in permissions or in the database, such as row and column security in modular embeds. Both are part of the Pro and Enterprise plans.

QuestionGuest embedmodular embedding (JWT)
Which plans?Every edition, including open sourcePro and Enterprise
Does each viewer get a Metabase account?NoYes, at first sign-in
Where does the tenant id go?A locked parameter in the signed tokenA user attribute in the signed JWT
What applies it?The locked filter on the embedded itemData permissions, such as row and column security
Drill-through and the query builder?Use modular embeddingIncluded
Do viewers count as billed users?NoYes

How do locked parameters keep each tenant’s data apart?

A locked parameter is a filter whose value your server puts in the signed token. The person viewing the embed can’t see it or change it. Guest embeds replace static embedding, the older way to embed with a signed JWT, and the token payload keeps the same fields: resource, params and exp.

  1. Turn on guest embeds in Admin > Embedding on open source, or in Admin > Embedding > Guest embeds on a paid plan.
  2. Add a dashboard filter called Tenant ID, and connect it to the tenant column on every card, because each card needs its own connection to the filter.
  3. Click Share, then Embed, and pick Guest. Set Tenant ID to Locked, then click Publish.
  4. Copy the embedding secret key from the embedding settings into your app’s environment as METABASE_SECRET_KEY.

The guest embeds page sets three rules that keep the locked filter safe:

  • Metabase refuses any token that leaves a locked parameter out, and it logs You must specify a value for :tenant_id in the JWT.
  • An empty array, [], turns the locked filter off for that token, so never sign one for the tenant.
  • The browser chooses the entityType and entityId it asks for, so check them against your own list before you sign.

The value must match your filter’s values exactly. To check the format, pick a value under Preview locked parameters in the embed wizard and copy the server code that Metabase generates.

Add the embed to the page that shows the dashboard. With guestEmbedProviderUri set, the embed asks your endpoint for a token when it loads, and again when it needs data after a token expires. This snippet follows the guest embeds page:

<script defer src="https://metabase.example.com/app/embed.js"></script>
<script>
  window.metabaseConfig = {
    isGuest: true,
    instanceUrl: "https://metabase.example.com",
    guestEmbedProviderUri: "/api/metabase/guest-token",
  };
</script>

<metabase-dashboard dashboard-id="12" with-title="true"></metabase-dashboard>

Modular embedding with JWT SSO: how do I pass the tenant id as a user attribute for row and column security?

With SSO, the tenant id travels as a user attribute instead of a filter value. Metabase saves extra key/value pairs in the JWT as user attributes, and it syncs them at every login.

  1. In Admin > Settings > Authentication > JWT, set the JWT Identity Provider URI to your endpoint, for example https://app.example.com/sso/metabase. Generate the signing key, store it in your app as METABASE_JWT_SHARED_SECRET, and click Save and enable, because the group mapping settings stay disabled until you save.
  2. In Admin > Embedding, turn on modular embedding, and add your app’s address under Cross-Origin Resource Sharing (CORS).
  3. Create a group called Customers. In the JWT settings, leave Group mapping on Automatic, the default, so the groups claim puts each user in that group. On Metabase 63, turn on Synchronize Group Memberships instead.
  4. In Admin > Permissions, block the All Users group first, because everyone in your Metabase belongs to it.
  5. For the Customers group, open View data on each table that holds tenant data. Pick Row and column security, choose the tenant column, and pick the tenant_id user attribute. Then set Create queries to Query builder only, so customers can drill through and build their own questions.

The attribute value must match the column value exactly, and the match is case-sensitive. The two kinds of embed use different keys, so don’t mix them up. The embedding secret key signs guest tokens, and the JWT signing key signs SSO tokens.

On the page, leave out isGuest. This snippet follows the modular embedding and query builder pages:

<script defer src="https://metabase.example.com/app/embed.js"></script>
<script>
  window.metabaseConfig = {
    instanceUrl: "https://metabase.example.com",
  };
</script>

<metabase-dashboard dashboard-id="12"></metabase-dashboard>
<metabase-question question-id="new" is-save-enabled="true"></metabase-question>

The second element embeds the query builder, so customers can build and save their own questions, and row and column security filters those questions too. The React SDK, which is also part of Pro and Enterprise, calls the same endpoint. For full app embedding, the endpoint signs the same JWT and redirects to /auth/sso with it, as the authentication page shows.

Server code: Next.js route handlers, Django views and a Rails controller

Both endpoints read the tenant id from your app’s own session, never from the request. The guest token endpoint follows the example endpoint on the guest embeds page. The SSO endpoint follows the JWT examples on the modular embedding authentication page, and it returns { "jwt": "..." }, the shape that page asks for. Those pages show Node.js, and the Django and Rails versions below sign the same payloads.

Next.js route handlers

// app/api/metabase/guest-token/route.ts
import jwt from "jsonwebtoken";
import { getCurrentUser } from "@/lib/auth"; // your app's own session helper

const METABASE_SECRET_KEY = process.env.METABASE_SECRET_KEY!;

// Items you published as guest embeds, each with a locked tenant_id filter.
const PUBLISHED = new Set(["dashboard:12", "question:34"]);

export async function POST(request: Request) {
  const user = await getCurrentUser();
  if (!user) {
    return Response.json({ error: "Not signed in" }, { status: 403 });
  }

  const { entityType, entityId } = await request.json();
  const id = Number(entityId);
  if (!PUBLISHED.has(`${entityType}:${id}`)) {
    return Response.json({ error: "Not allowed" }, { status: 403 });
  }

  const token = jwt.sign(
    {
      resource: { [entityType]: id },
      params: { tenant_id: [user.tenantId] }, // from your session, never from the browser
      exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minutes
    },
    METABASE_SECRET_KEY,
  );
  return Response.json({ jwt: token });
}
// app/sso/metabase/route.ts
import jwt from "jsonwebtoken";
import { getCurrentUser } from "@/lib/auth"; // your app's own session helper

export const dynamic = "force-dynamic"; // sign a fresh token on every request

const METABASE_JWT_SHARED_SECRET = process.env.METABASE_JWT_SHARED_SECRET!;

export async function GET() {
  const user = await getCurrentUser();
  if (!user) {
    return Response.json({ error: "Not signed in" }, { status: 401 });
  }

  const token = jwt.sign(
    {
      email: user.email,
      first_name: user.firstName,
      last_name: user.lastName,
      groups: ["Customers"],
      tenant_id: String(user.tenantId), // becomes a user attribute in Metabase
      exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minutes
    },
    METABASE_JWT_SHARED_SECRET,
  );
  return Response.json({ jwt: token });
}

Django views

The embed posts JSON without Django’s CSRF token, so the token view is exempt from CSRF checks. It changes no data, and it still needs a signed-in session. Install PyJWT with pip install PyJWT.

# analytics/views.py
import json
import time

import jwt  # PyJWT
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST

# Items you published as guest embeds, each with a locked tenant_id filter.
PUBLISHED = {("dashboard", 12), ("question", 34)}

@csrf_exempt  # the embed posts JSON without a CSRF token; this view changes no data
@require_POST
def metabase_guest_token(request):
    if not request.user.is_authenticated:
        return JsonResponse({"error": "Not signed in"}, status=403)
    try:
        body = json.loads(request.body)
        entity_type, entity_id = body["entityType"], int(body["entityId"])
    except (ValueError, KeyError, TypeError):
        return JsonResponse({"error": "Bad request"}, status=400)
    if (entity_type, entity_id) not in PUBLISHED:
        return JsonResponse({"error": "Not allowed"}, status=403)

    payload = {
        "resource": {entity_type: entity_id},
        "params": {"tenant_id": [request.user.tenant_id]},  # from your session only
        "exp": round(time.time()) + 10 * 60,  # 10 minutes
    }
    token = jwt.encode(payload, settings.METABASE_SECRET_KEY, algorithm="HS256")
    return JsonResponse({"jwt": token})

@require_GET
def metabase_sso(request):
    user = request.user
    if not user.is_authenticated:
        return JsonResponse({"error": "Not signed in"}, status=401)
    payload = {
        "email": user.email,
        "first_name": user.first_name,
        "last_name": user.last_name,
        "groups": ["Customers"],
        "tenant_id": str(user.tenant_id),  # becomes a user attribute in Metabase
        "exp": round(time.time()) + 10 * 60,  # 10 minutes
    }
    token = jwt.encode(payload, settings.METABASE_JWT_SHARED_SECRET, algorithm="HS256")
    return JsonResponse({"jwt": token})
# urls.py
from django.urls import path

from analytics import views

urlpatterns = [
    path("api/metabase/guest-token", views.metabase_guest_token),
    path("sso/metabase", views.metabase_sso),
]

Read METABASE_SECRET_KEY and METABASE_JWT_SHARED_SECRET from the environment in your settings file. Replace tenant_id with the field where your user model keeps the customer.

Rails controller

Add gem "jwt" to your Gemfile. The token action skips forgery protection for the same reason as the Django view.

# app/controllers/metabase_controller.rb
class MetabaseController < ApplicationController
  # The embed posts JSON without a CSRF token. This action changes no data.
  skip_forgery_protection only: :guest_token
  before_action :require_signed_in_user

  # Items you published as guest embeds, each with a locked tenant_id filter.
  PUBLISHED = [["dashboard", 12], ["question", 34]].freeze

  def guest_token
    body = JSON.parse(request.raw_post)
    entity = [body["entityType"], Integer(body["entityId"], exception: false)]
    unless PUBLISHED.include?(entity)
      return render json: { error: "Not allowed" }, status: :forbidden
    end

    payload = {
      resource: { entity[0] => entity[1] },
      params: { tenant_id: [current_user.tenant_id] }, # from your session only
      exp: Time.now.to_i + 10 * 60 # 10 minutes
    }
    render json: { jwt: JWT.encode(payload, ENV.fetch("METABASE_SECRET_KEY"), "HS256") }
  rescue JSON::ParserError, TypeError
    render json: { error: "Bad request" }, status: :bad_request
  end

  def sso
    payload = {
      email: current_user.email,
      first_name: current_user.first_name,
      last_name: current_user.last_name,
      groups: ["Customers"],
      tenant_id: current_user.tenant_id.to_s, # becomes a user attribute in Metabase
      exp: Time.now.to_i + 10 * 60 # 10 minutes
    }
    render json: { jwt: JWT.encode(payload, ENV.fetch("METABASE_JWT_SHARED_SECRET"), "HS256") }
  end

  private

  # current_user comes from your app's own sign-in (Devise or your own code).
  def require_signed_in_user
    render json: { error: "Not signed in" }, status: :unauthorized unless current_user
  end
end
# config/routes.rb, inside the routes.draw block
post "/api/metabase/guest-token", to: "metabase#guest_token"
get "/sso/metabase", to: "metabase#sso"

Should I turn on tenants?

Tenants are part of the Pro and Enterprise plans. They group each customer’s users, so one set of dashboards and permissions serves every customer. In the words of the tenants page, “each tenant only ever sees their own data.”

To start, choose Multi-tenant strategy from the gear icon in Admin settings > People. Turn on tenants before your customers first sign in, because Metabase treats every existing user as an internal user. For customers who signed in earlier, mark each one as a tenant user with PUT /api/user/:id and {"tenant_id": ...}, the Metabase id of their tenant, before you add the claim. Otherwise Metabase refuses their login with “Cannot add tenant claim to internal user”. The tenants page shows the steps under “Changing tenant strategy”.

Then add one claim to the SSO payload: "@tenant": String(user.tenantId) in Next.js, "@tenant": str(user.tenant_id) in Django, or "@tenant" => current_user.tenant_id.to_s in Rails. If the tenant doesn’t exist yet, Metabase creates it at the user’s first login, with the claim’s value as the tenant slug.

Use your tenant id as the slug. Every tenant user then gets a @tenant.slug attribute, and row and column security can filter the tenant column with it. Set data permissions on the All tenant users group or on tenant groups. Tenant users can’t join internal groups, so take the internal Customers group out of the groups claim.

Tenants also give each customer a tenant collection. Its users can save questions there and share them with their own team, and users from other tenants never see them.

What about SQL questions, a database per tenant, or a schema per tenant?

Row and column security applies to questions built in the query builder. For SQL questions and the SQL editor, use connection impersonation. Metabase then connects with the database role named in each person’s user attribute, and your database’s row-level security policies decide what that role sees. Impersonation is part of Pro and Enterprise, and it works with ClickHouse, MySQL, PostgreSQL, Redshift, Snowflake, SQL Server and Starburst/Trino.

In a guest embed, a locked parameter also filters SQL cards, once each card has a field filter or variable connected to the tenant filter. If each customer has its own database with the same schema, database routing, which is also part of Pro and Enterprise, sends each person’s queries to their own database. If each customer has its own schema, give each customer’s group access to its own tables only.

Which plan do I need? Multi-tenant embedding pricing

Guest embeds with locked parameters work on the free open-source edition and on every paid plan. The open-source edition is self-hosted and free, with unlimited users. Starter runs on Metabase Cloud for $100 a month, or $90 a month billed yearly.

Modular embeds, row and column security, tenants, impersonation, database routing and the embedded query builder are part of the Pro and Enterprise plans. Pro costs $575 a month with 10 users included, then $12 a month for each extra user, and every customer user who signs in through SSO counts. Enterprise starts at $20,000 a year, has the same features as Pro, and offers custom pricing for apps that serve many end customers.

PlanPrice (checked 2026-10-07)Multi-tenant embedding
Open SourceFree, self-hostedGuest embeds with locked parameters
Starter$100 a month, or $90 a month billed yearlyGuest embeds with locked parameters
Pro$575 a month, 10 users included, $12 per extra userGuest embeds, modular embeds, React SDK row and column security, tenants, impersonation, database routing
EnterpriseFrom $20,000 a yearSame features as Pro, with custom pricing

Next steps

# Multi-tenant embedded analytics: show each customer only its own data

In multi-tenant embedded analytics, your server signs the signed-in user's tenant id into the embed token, and Metabase filters the data with it. Guest embeds do this on every edition, including the free open-source edition, with a locked parameter that only your server can set. On Pro and Enterprise, a Modular embed passes the tenant id as a user attribute, and row and column security shows each customer only the rows that match it.

Your app already decides which customer each signed-in user belongs to, so Metabase doesn't need a second copy of that rule. The tenant id arrives in a token that your server signs, which means the browser can't change it. This guide sets up both kinds of embed, with server code for Next.js, Django and Rails.

## Guest embedding or Modular embedding: which one fits a multi-tenant app?

Guest embeds work on every Metabase edition, including open source. Viewers don't need a Metabase account, and they don't count as billed users on any plan. On the open-source and Starter plans, guest embeds show a "Powered by Metabase" banner, and removing it needs Pro or Enterprise.

Modular embeds sign each viewer in through single sign-on, here with JWT, and they're part of the Pro and Enterprise plans. Metabase creates an account for each viewer the first time your app signs them in, and each account counts as a user on your bill. Because Metabase knows who is looking, the same tenant rule covers drill-through and every question a customer builds in the query builder.

You can use only one of the two methods on a given page of your app. Metabase's data isolation guide recommends locked parameters when you want to filter data without Metabase accounts. For sensitive data, it recommends a method that enforces access in permissions or in the database, such as row and column security in modular embeds. Both are part of the Pro and Enterprise plans.

| Question | Guest embed | modular embedding (JWT) |
|---|---|---|
| Which plans? | Every edition, including open source | Pro and Enterprise |
| Does each viewer get a Metabase account? | No | Yes, at first sign-in |
| Where does the tenant id go? | A locked parameter in the signed token | A user attribute in the signed JWT |
| What applies it? | The locked filter on the embedded item | Data permissions, such as row and column security |
| Drill-through and the query builder? | Use modular embedding | Included |
| Do viewers count as billed users? | No | Yes |

## How do locked parameters keep each tenant's data apart?

A locked parameter is a filter whose value your server puts in the signed token. The person viewing the embed can't see it or change it. Guest embeds replace static embedding, the older way to embed with a signed JWT, and the token payload keeps the same fields: `resource`, `params` and `exp`.

1. Turn on guest embeds in **Admin > Embedding** on open source, or in **Admin > Embedding > Guest embeds** on a paid plan.
2. Add a dashboard filter called Tenant ID, and connect it to the tenant column on every card, because each card needs its own connection to the filter.
3. Click **Share**, then **Embed**, and pick **Guest**. Set Tenant ID to **Locked**, then click **Publish**.
4. Copy the embedding secret key from the embedding settings into your app's environment as `METABASE_SECRET_KEY`.

The guest embeds page sets three rules that keep the locked filter safe:

- Metabase refuses any token that leaves a locked parameter out, and it logs `You must specify a value for :tenant_id in the JWT`.
- An empty array, `[]`, turns the locked filter off for that token, so never sign one for the tenant.
- The browser chooses the `entityType` and `entityId` it asks for, so check them against your own list before you sign.

The value must match your filter's values exactly. To check the format, pick a value under **Preview locked parameters** in the embed wizard and copy the server code that Metabase generates.

Add the embed to the page that shows the dashboard. With `guestEmbedProviderUri` set, the embed asks your endpoint for a token when it loads, and again when it needs data after a token expires. This snippet follows the guest embeds page:

```html
<script defer src="https://metabase.example.com/app/embed.js"></script>
<script>
  window.metabaseConfig = {
    isGuest: true,
    instanceUrl: "https://metabase.example.com",
    guestEmbedProviderUri: "/api/metabase/guest-token",
  };
</script>

<metabase-dashboard dashboard-id="12" with-title="true"></metabase-dashboard>
```

## Modular embedding with JWT SSO: how do I pass the tenant id as a user attribute for row and column security?

With SSO, the tenant id travels as a user attribute instead of a filter value. Metabase saves extra key/value pairs in the JWT as user attributes, and it syncs them at every login.

1. In **Admin > Settings > Authentication > JWT**, set the JWT Identity Provider URI to your endpoint, for example `https://app.example.com/sso/metabase`. Generate the signing key, store it in your app as `METABASE_JWT_SHARED_SECRET`, and click **Save and enable**, because the group mapping settings stay disabled until you save.
2. In **Admin > Embedding**, turn on modular embedding, and add your app's address under **Cross-Origin Resource Sharing (CORS)**.
3. Create a group called Customers. In the JWT settings, leave **Group mapping** on **Automatic**, the default, so the `groups` claim puts each user in that group. On Metabase 63, turn on **Synchronize Group Memberships** instead.
4. In **Admin > Permissions**, block the All Users group first, because everyone in your Metabase belongs to it.
5. For the Customers group, open **View data** on each table that holds tenant data. Pick **Row and column security**, choose the tenant column, and pick the `tenant_id` user attribute. Then set **Create queries** to **Query builder only**, so customers can drill through and build their own questions.

The attribute value must match the column value exactly, and the match is case-sensitive. The two kinds of embed use different keys, so don't mix them up. The embedding secret key signs guest tokens, and the JWT signing key signs SSO tokens.

On the page, leave out `isGuest`. This snippet follows the modular embedding and query builder pages:

```html
<script defer src="https://metabase.example.com/app/embed.js"></script>
<script>
  window.metabaseConfig = {
    instanceUrl: "https://metabase.example.com",
  };
</script>

<metabase-dashboard dashboard-id="12"></metabase-dashboard>
<metabase-question question-id="new" is-save-enabled="true"></metabase-question>
```

The second element embeds the query builder, so customers can build and save their own questions, and row and column security filters those questions too. The React SDK, which is also part of Pro and Enterprise, calls the same endpoint. For full app embedding, the endpoint signs the same JWT and redirects to `/auth/sso` with it, as the authentication page shows.

## Server code: Next.js route handlers, Django views and a Rails controller

Both endpoints read the tenant id from your app's own session, never from the request. The guest token endpoint follows the example endpoint on the guest embeds page. The SSO endpoint follows the JWT examples on the modular embedding authentication page, and it returns `{ "jwt": "..." }`, the shape that page asks for. Those pages show Node.js, and the Django and Rails versions below sign the same payloads.

### Next.js route handlers

```typescript
// app/api/metabase/guest-token/route.ts
import jwt from "jsonwebtoken";
import { getCurrentUser } from "@/lib/auth"; // your app's own session helper

const METABASE_SECRET_KEY = process.env.METABASE_SECRET_KEY!;

// Items you published as guest embeds, each with a locked tenant_id filter.
const PUBLISHED = new Set(["dashboard:12", "question:34"]);

export async function POST(request: Request) {
  const user = await getCurrentUser();
  if (!user) {
    return Response.json({ error: "Not signed in" }, { status: 403 });
  }

  const { entityType, entityId } = await request.json();
  const id = Number(entityId);
  if (!PUBLISHED.has(`${entityType}:${id}`)) {
    return Response.json({ error: "Not allowed" }, { status: 403 });
  }

  const token = jwt.sign(
    {
      resource: { [entityType]: id },
      params: { tenant_id: [user.tenantId] }, // from your session, never from the browser
      exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minutes
    },
    METABASE_SECRET_KEY,
  );
  return Response.json({ jwt: token });
}
```

```typescript
// app/sso/metabase/route.ts
import jwt from "jsonwebtoken";
import { getCurrentUser } from "@/lib/auth"; // your app's own session helper

export const dynamic = "force-dynamic"; // sign a fresh token on every request

const METABASE_JWT_SHARED_SECRET = process.env.METABASE_JWT_SHARED_SECRET!;

export async function GET() {
  const user = await getCurrentUser();
  if (!user) {
    return Response.json({ error: "Not signed in" }, { status: 401 });
  }

  const token = jwt.sign(
    {
      email: user.email,
      first_name: user.firstName,
      last_name: user.lastName,
      groups: ["Customers"],
      tenant_id: String(user.tenantId), // becomes a user attribute in Metabase
      exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minutes
    },
    METABASE_JWT_SHARED_SECRET,
  );
  return Response.json({ jwt: token });
}
```

### Django views

The embed posts JSON without Django's CSRF token, so the token view is exempt from CSRF checks. It changes no data, and it still needs a signed-in session. Install PyJWT with `pip install PyJWT`.

```python
# analytics/views.py
import json
import time

import jwt  # PyJWT
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST

# Items you published as guest embeds, each with a locked tenant_id filter.
PUBLISHED = {("dashboard", 12), ("question", 34)}

@csrf_exempt  # the embed posts JSON without a CSRF token; this view changes no data
@require_POST
def metabase_guest_token(request):
    if not request.user.is_authenticated:
        return JsonResponse({"error": "Not signed in"}, status=403)
    try:
        body = json.loads(request.body)
        entity_type, entity_id = body["entityType"], int(body["entityId"])
    except (ValueError, KeyError, TypeError):
        return JsonResponse({"error": "Bad request"}, status=400)
    if (entity_type, entity_id) not in PUBLISHED:
        return JsonResponse({"error": "Not allowed"}, status=403)

    payload = {
        "resource": {entity_type: entity_id},
        "params": {"tenant_id": [request.user.tenant_id]},  # from your session only
        "exp": round(time.time()) + 10 * 60,  # 10 minutes
    }
    token = jwt.encode(payload, settings.METABASE_SECRET_KEY, algorithm="HS256")
    return JsonResponse({"jwt": token})

@require_GET
def metabase_sso(request):
    user = request.user
    if not user.is_authenticated:
        return JsonResponse({"error": "Not signed in"}, status=401)
    payload = {
        "email": user.email,
        "first_name": user.first_name,
        "last_name": user.last_name,
        "groups": ["Customers"],
        "tenant_id": str(user.tenant_id),  # becomes a user attribute in Metabase
        "exp": round(time.time()) + 10 * 60,  # 10 minutes
    }
    token = jwt.encode(payload, settings.METABASE_JWT_SHARED_SECRET, algorithm="HS256")
    return JsonResponse({"jwt": token})
```

```python
# urls.py
from django.urls import path

from analytics import views

urlpatterns = [
    path("api/metabase/guest-token", views.metabase_guest_token),
    path("sso/metabase", views.metabase_sso),
]
```

Read `METABASE_SECRET_KEY` and `METABASE_JWT_SHARED_SECRET` from the environment in your settings file. Replace `tenant_id` with the field where your user model keeps the customer.

### Rails controller

Add `gem "jwt"` to your Gemfile. The token action skips forgery protection for the same reason as the Django view.

```ruby
# app/controllers/metabase_controller.rb
class MetabaseController < ApplicationController
  # The embed posts JSON without a CSRF token. This action changes no data.
  skip_forgery_protection only: :guest_token
  before_action :require_signed_in_user

  # Items you published as guest embeds, each with a locked tenant_id filter.
  PUBLISHED = [["dashboard", 12], ["question", 34]].freeze

  def guest_token
    body = JSON.parse(request.raw_post)
    entity = [body["entityType"], Integer(body["entityId"], exception: false)]
    unless PUBLISHED.include?(entity)
      return render json: { error: "Not allowed" }, status: :forbidden
    end

    payload = {
      resource: { entity[0] => entity[1] },
      params: { tenant_id: [current_user.tenant_id] }, # from your session only
      exp: Time.now.to_i + 10 * 60 # 10 minutes
    }
    render json: { jwt: JWT.encode(payload, ENV.fetch("METABASE_SECRET_KEY"), "HS256") }
  rescue JSON::ParserError, TypeError
    render json: { error: "Bad request" }, status: :bad_request
  end

  def sso
    payload = {
      email: current_user.email,
      first_name: current_user.first_name,
      last_name: current_user.last_name,
      groups: ["Customers"],
      tenant_id: current_user.tenant_id.to_s, # becomes a user attribute in Metabase
      exp: Time.now.to_i + 10 * 60 # 10 minutes
    }
    render json: { jwt: JWT.encode(payload, ENV.fetch("METABASE_JWT_SHARED_SECRET"), "HS256") }
  end

  private

  # current_user comes from your app's own sign-in (Devise or your own code).
  def require_signed_in_user
    render json: { error: "Not signed in" }, status: :unauthorized unless current_user
  end
end
```

```ruby
# config/routes.rb, inside the routes.draw block
post "/api/metabase/guest-token", to: "metabase#guest_token"
get "/sso/metabase", to: "metabase#sso"
```

## Should I turn on tenants?

Tenants are part of the Pro and Enterprise plans. They group each customer's users, so one set of dashboards and permissions serves every customer. In the words of the tenants page, "each tenant only ever sees their own data."

To start, choose **Multi-tenant strategy** from the gear icon in **Admin settings > People**. Turn on tenants before your customers first sign in, because Metabase treats every existing user as an internal user. For customers who signed in earlier, mark each one as a tenant user with `PUT /api/user/:id` and `{"tenant_id": ...}`, the Metabase id of their tenant, before you add the claim. Otherwise Metabase refuses their login with "Cannot add tenant claim to internal user". The tenants page shows the steps under "Changing tenant strategy".

Then add one claim to the SSO payload: `"@tenant": String(user.tenantId)` in Next.js, `"@tenant": str(user.tenant_id)` in Django, or `"@tenant" => current_user.tenant_id.to_s` in Rails. If the tenant doesn't exist yet, Metabase creates it at the user's first login, with the claim's value as the tenant slug.

Use your tenant id as the slug. Every tenant user then gets a `@tenant.slug` attribute, and row and column security can filter the tenant column with it. Set data permissions on the All tenant users group or on tenant groups. Tenant users can't join internal groups, so take the internal Customers group out of the `groups` claim.

Tenants also give each customer a tenant collection. Its users can save questions there and share them with their own team, and users from other tenants never see them.

## What about SQL questions, a database per tenant, or a schema per tenant?

Row and column security applies to questions built in the query builder. For SQL questions and the SQL editor, use connection impersonation. Metabase then connects with the database role named in each person's user attribute, and your database's row-level security policies decide what that role sees. Impersonation is part of Pro and Enterprise, and it works with ClickHouse, MySQL, PostgreSQL, Redshift, Snowflake, SQL Server and Starburst/Trino.

In a guest embed, a locked parameter also filters SQL cards, once each card has a field filter or variable connected to the tenant filter. If each customer has its own database with the same schema, database routing, which is also part of Pro and Enterprise, sends each person's queries to their own database. If each customer has its own schema, give each customer's group access to its own tables only.

## Which plan do I need? Multi-tenant embedding pricing

Guest embeds with locked parameters work on the free open-source edition and on every paid plan. The open-source edition is self-hosted and free, with unlimited users. Starter runs on Metabase Cloud for $100 a month, or $90 a month billed yearly.

Modular embeds, row and column security, tenants, impersonation, database routing and the embedded query builder are part of the Pro and Enterprise plans. Pro costs $575 a month with 10 users included, then $12 a month for each extra user, and every customer user who signs in through SSO counts. Enterprise starts at $20,000 a year, has the same features as Pro, and offers custom pricing for apps that serve many end customers.

| Plan | Price (checked 2026-10-07) | Multi-tenant embedding |
|---|---|---|
| Open Source | Free, self-hosted | Guest embeds with locked parameters |
| Starter | $100 a month, or $90 a month billed yearly | Guest embeds with locked parameters |
| Pro | $575 a month, 10 users included, $12 per extra user | Guest embeds, modular embeds, React SDK row and column security, tenants, impersonation, database routing |
| Enterprise | From $20,000 a year | Same features as Pro, with custom pricing |

## Next steps

- [Guest embeds](/docs/latest/embedding/guest-embedding)
- [Modular embedding: authentication](/docs/latest/embedding/authentication)
- [JWT-based authentication](/docs/latest/people-and-groups/authenticating-with-jwt)
- [Row and column security](/docs/latest/permissions/row-and-column-security)
- [Tenants](/docs/latest/embedding/tenants)
- [User attributes](/docs/latest/people-and-groups/managing#adding-a-user-attribute)
- [Data isolation methods](/docs/latest/permissions/data-isolation-methods)
- [Pricing](/pricing)