How to Set Up Google Workspace SSO for Apache Superset

For a company on Google Workspace, “Sign in with Google” is the SSO users already expect, and Flask-AppBuilder has a Google handler built in, so the first version of this integration takes fifteen minutes. The reason it deserves a full guide is what the first version gets wrong.

Two things. First, Google’s OAuth flow will happily authenticate any Google account, including personal Gmail, unless you restrict it. Second, Google’s user info response contains a name and an email address and nothing about groups, so Superset’s AUTH_ROLES_MAPPING has nothing to map from and every user lands in the default role. Both are fixable in superset_config.py, and the second has three solutions of increasing power.

The general mechanics of Superset OAuth, proxy settings and the break-glass procedure are in our Okta guide and are not repeated here. Written against Superset 4.x.

Step 1: Create the Google OAuth client

In the Google Cloud console, in a project owned by your organisation:

  • Go to APIs & Services, OAuth consent screen. Choose Internal as the user type. This alone restricts sign-in to accounts in your Workspace organisation and is the single most important setting in this guide. Fill in the app name (“Superset BI”), support email and developer contact. Add the scopes openid, .../auth/userinfo.email and .../auth/userinfo.profile. Save.
  • Go to Credentials, Create credentials, OAuth client ID. Application type Web application. Name it “Superset”.
  • Under Authorised JavaScript origins, add https://superset.example.com.
  • Under Authorised redirect URIs, add https://superset.example.com/oauth-authorized/google. The last segment must match the provider name in Superset’s config.
  • Create, and copy the Client ID and Client secret.

If your consent screen must be External (for example, contractors on a different Workspace domain need access), you lose the automatic organisation restriction, and the domain enforcement in Step 2 becomes mandatory rather than belt-and-braces.

Step 2: Configure Superset

python
import os
from flask_appbuilder.security.manager import AUTH_OAUTH

AUTH_TYPE = AUTH_OAUTH

WORKSPACE_DOMAIN = "example.com"

OAUTH_PROVIDERS = [
    {
        "name": "google",
        "icon": "fa-google",
        "token_key": "access_token",
        "remote_app": {
            "client_id": os.environ["GOOGLE_CLIENT_ID"],
            "client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
            "api_base_url": "https://www.googleapis.com/oauth2/v2/",
            "client_kwargs": {
                "scope": "openid email profile",
                # hd is a hint that pre-selects the Workspace account picker.
                # It is NOT enforcement; enforcement is in the security manager below.
                "hd": WORKSPACE_DOMAIN,
                "prompt": "select_account",
            },
            "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
            "request_token_url": None,
            "access_token_url": "https://accounts.google.com/o/oauth2/token",
            "authorize_url": "https://accounts.google.com/o/oauth2/auth",
        },
    }
]

AUTH_USER_REGISTRATION = True
AUTH_USER_REGISTRATION_ROLE = "Gamma"
AUTH_ROLES_SYNC_AT_LOGIN = True

ENABLE_PROXY_FIX = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"

With only this, any user your consent screen allows can log in and will receive the Gamma role. The next section adds domain enforcement and role assignment in a custom security manager.

SSO is the easy half of access control

Mapping Google Groups to Superset roles, keeping that sync from drifting, and layering row-level security on top is where most SSO setups quietly rot. A Superset Architecture Review covers all three.

Book a Superset Architecture Review

Step 3: Enforce the domain and assign roles

FAB’s Google handler returns username, first_name, last_name and email, and no role_keys. Override oauth_user_info to add both checks. Three options follow; pick one.

Option A: Roles by domain rule

The simplest useful version. Everyone in the domain is a viewer; an explicit list of emails are analysts or admins.

python
from flask import flash, redirect, url_for
from superset.security import SupersetSecurityManager

ADMINS = {"data.lead@example.com", "platform@example.com"}
ANALYSTS = {"finance.analyst@example.com", "ops.analyst@example.com"}


class GoogleSecurityManager(SupersetSecurityManager):
    def oauth_user_info(self, provider, response=None):
        if provider != "google":
            return super().oauth_user_info(provider, response)

        me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo").json()
        email = (me.get("email") or "").lower()

        # Enforcement: refuse anything outside the Workspace domain.
        if not email.endswith("@" + WORKSPACE_DOMAIN) or not me.get("verified_email", True):
            return {}

        if email in ADMINS:
            role_keys = ["admin"]
        elif email in ANALYSTS:
            role_keys = ["analyst"]
        else:
            role_keys = ["viewer"]

        return {
            "username": email,
            "first_name": me.get("given_name", ""),
            "last_name": me.get("family_name", ""),
            "email": email,
            "role_keys": role_keys,
        }


CUSTOM_SECURITY_MANAGER = GoogleSecurityManager

AUTH_ROLES_MAPPING = {
    "admin": ["Admin"],
    "analyst": ["Alpha", "sql_lab"],
    "viewer": ["Gamma"],
}

Returning an empty dict makes FAB refuse the login. The allow-lists live in configuration, which is fine for a team of thirty and unpleasant for a company of three hundred. That is when you move to Option C.

Option B: Roles by email pattern

A middle step for organisations with structured addresses or aliases: a mapping from regex to role key.

python
import re

ROLE_PATTERNS = [
    (re.compile(r"^(data|bi)-admin@example\.com$"), "admin"),
    (re.compile(r"^.+\.analyst@example\.com$"), "analyst"),
]

def role_keys_for(email):
    for pattern, key in ROLE_PATTERNS:
        if pattern.match(email):
            return [key]
    return ["viewer"]

Substitute role_keys_for(email) into Option A. It scales a little further and is still visible in config.

Option C: Roles from Google Groups via the Directory API

The correct long-term answer: Google Groups are the source of truth, and Superset looks up the user’s groups at login. This uses the Admin SDK Directory API with a service account that has domain-wide delegation.

Setup in Google Cloud and Workspace admin:

  • Enable the Admin SDK API in the Cloud project.
  • Create a service account, create a JSON key for it, and note its client ID (the numeric one).
  • In the Workspace admin console, Security, Access and data control, API controls, Domain-wide delegation, add the service account’s client ID with the scope https://www.googleapis.com/auth/admin.directory.group.readonly.
  • Pick a Workspace admin account the service account will impersonate (a dedicated, low-privilege admin role that can read groups is enough).

Then in Superset:

python
import os
from google.oauth2 import service_account
from googleapiclient.discovery import build
from superset.security import SupersetSecurityManager

DIRECTORY_SCOPES = ["https://www.googleapis.com/auth/admin.directory.group.readonly"]
IMPERSONATE = os.environ["GOOGLE_DIRECTORY_ADMIN"]          # e.g. directory-reader@example.com
SA_KEY_PATH = os.environ["GOOGLE_DIRECTORY_SA_KEY"]          # mounted JSON key

_directory = None

def directory():
    global _directory
    if _directory is None:
        creds = service_account.Credentials.from_service_account_file(SA_KEY_PATH, scopes=DIRECTORY_SCOPES)
        creds = creds.with_subject(IMPERSONATE)
        _directory = build("admin", "directory_v1", credentials=creds, cache_discovery=False)
    return _directory


def google_groups_for(email):
    groups = []
    page_token = None
    while True:
        resp = directory().groups().list(userKey=email, pageToken=page_token).execute()
        groups.extend(g["email"] for g in resp.get("groups", []))
        page_token = resp.get("nextPageToken")
        if not page_token:
            return groups


class GoogleGroupsSecurityManager(SupersetSecurityManager):
    def oauth_user_info(self, provider, response=None):
        if provider != "google":
            return super().oauth_user_info(provider, response)
        me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo").json()
        email = (me.get("email") or "").lower()
        if not email.endswith("@" + WORKSPACE_DOMAIN):
            return {}
        try:
            role_keys = google_groups_for(email)
        except Exception as exc:  # directory outage should not lock everyone out
            self.appbuilder.get_app.logger.error("Directory lookup failed for %s: %s", email, exc)
            role_keys = []
        return {
            "username": email,
            "first_name": me.get("given_name", ""),
            "last_name": me.get("family_name", ""),
            "email": email,
            "role_keys": role_keys,
        }


CUSTOM_SECURITY_MANAGER = GoogleGroupsSecurityManager

# Google Group email -> Superset roles
AUTH_ROLES_MAPPING = {
    "superset-admins@example.com": ["Admin"],
    "superset-analysts@example.com": ["Alpha", "sql_lab"],
    "superset-viewers@example.com": ["Gamma"],
}

Install google-api-python-client and google-auth into the Superset image. The lookup adds a few hundred milliseconds to login, once per session, which nobody notices. Decide deliberately what happens when the Directory API is unreachable: the code above falls back to the registration role so people can still see public dashboards, which is usually the right trade-off. If you would rather fail closed, return {} in the exception handler.

Step 4: Test

  • Restart Superset. The login page shows “Sign in with google”.
  • Sign in with an admin account. Check Settings, List Users for the expected role.
  • In a private window, attempt sign-in with a personal Gmail account. With an Internal consent screen, Google refuses before Superset is involved. With an External screen, the security manager refuses and you land back on the login page.
  • For Option C, add a user to superset-analysts@example.com, wait a minute for Google to propagate, sign out and in, and confirm SQL Lab appears.

Troubleshooting

Symptom Cause Fix
Error 400: redirect_uri_mismatch Redirect URI not registered exactly Add https://superset.example.com/oauth-authorized/google; enable ENABLE_PROXY_FIX
Error 403: org_internal Consent screen is Internal and the account is outside the organisation Expected for outsiders; for legitimate external users switch to External and rely on the security manager
Personal Gmail accounts can log in Consent screen External and no domain check Add the domain enforcement from Step 3
Everyone is Gamma No role_keys returned Implement Option A, B or C; the built-in handler returns none
Option C: 403 Not Authorized to access this resource/api Domain-wide delegation missing or wrong scope Add the service account client ID with the group read-only scope in Workspace admin
Option C: 400 Bad Request: Invalid Input: memberKey Impersonated account lacks group read permission Impersonate an account with a Groups Reader admin role
mismatching_state Session cookie not returned on callback SESSION_COOKIE_SAMESITE = "Lax", SESSION_COOKIE_SECURE matching the scheme

Operational notes

  • Offboarding. Suspending a user in Workspace blocks new logins immediately. Existing Superset sessions last until PERMANENT_SESSION_LIFETIME; keep it short.
  • Multiple domains. Workspace organisations with secondary domains should check membership of a set rather than a single suffix.
  • Service account key hygiene (Option C). Mount it as a secret, never in the image, and rotate on the same schedule as other credentials.
  • 2-Step Verification and Google’s context-aware access policies apply to Superset automatically, which is a good part of the reason to do this.

Frequently Asked Questions

Can I restrict Apache Superset sign-in to my Google Workspace domain?

Yes, and it is worth doing in two places. Setting the OAuth consent screen to Internal restricts sign-in to accounts in your Workspace organisation and is the single most important setting in the setup. Then check the email domain again server-side in oauth_user_info, returning an empty dict for anything outside it, so the restriction survives a consent screen that later has to change to External for contractors.

Is the hd parameter enough to block personal Gmail accounts?

No. hd is a hint that pre-selects the right account in Google’s account picker; it is not enforcement, and the flow can still be completed with a personal account. Enforcement is the Internal consent screen plus the explicit domain check in the security manager. Treat hd purely as a usability improvement.

How do I map Google Groups to Superset roles?

Google’s user info response carries a name and an email address and nothing about group membership, so AUTH_ROLES_MAPPING has nothing to match and every user lands in the default role. The long-term answer is to look the user’s groups up at login through the Admin SDK Directory API, using a service account with domain-wide delegation and the group read-only scope. For a team of thirty, allow-lists or email patterns in configuration are a reasonable interim step.

Do I need a service account to use Google Workspace SSO with Superset?

Only for group-based role assignment. Authentication itself needs nothing more than the OAuth client ID and secret. The service account, its JSON key and the domain-wide delegation grant exist solely so Superset can ask the Directory API which groups a user belongs to. If roles are assigned by allow-list or email pattern instead, skip it and avoid the key-rotation burden that comes with it.

Build It With Andolasoft

Getting the sign-in right is the first step; deciding what each role can see is the second. A Superset Architecture Review covers authentication, role design and row-level security together with caching, async queries and upgrade readiness, in five working days with a written report. If you want the whole platform run for you, service-account key rotation included, that is Managed Support. For the broader set of controls, see governance and security best practices for Superset.

If your Workspace organisation is on Superset and everyone is still landing in the same role, talk to us. We will map your Google Groups to a Superset role model that holds up, and give you a fixed-scope plan to get there.

Superset RBAC Design for a 500-User Enterprise: Roles, Data Access and Dashboard Permissions

Superset’s permission model is fine-grained enough to express almost any access policy and coarse enough in its defaults that most deployments never use it properly. The result is familiar: everyone who builds anything is Alpha, everyone else is Gamma with datasource access granted one dataset at a time by whoever was asked, and nobody can say who can see the payroll table without clicking through the UI.This post is the design we use when a deployment has to serve several departments with different data, several kinds of user with different capabilities, and a security team that wants to read the policy rather than reverse-engineer it. It applies to Superset 3.x and 4.x.

How Superset permissions actually work

Superset inherits its security model from Flask-AppBuilder. Three concepts:

  • A permission is an action, such as can_read, can_write, can_export, menu_access, datasource_access, schema_access, database_access, all_datasource_access, all_database_access, can_sql_json.
  • A view menu is the thing the action applies to: a REST API (Dashboard, Chart, Dataset), a menu entry (SQL Lab, Settings), or a data object named like [analytics].[finance].[gl_entries](id:42) for a dataset or [analytics].[finance] for a schema.
  • A permission view is the pair, and a role is a set of permission views. Users hold any number of roles, and their effective permissions are the union.

Two consequences shape the design. First, because permissions are unioned, roles can be small and composable; a user gets capabilities from one role and data from another. Second, there is no deny. If any role grants access to a dataset, the user has it. Design for that.

Dataset permissions are created automatically when a dataset is created and named after it, so the permission list grows with the catalogue. Schema permissions (schema_access on [db].[schema]) cover every current and future dataset in that schema, which is the lever that makes the model scale.

The default roles and where they stop

Role What it grants Realistic use
Admin Everything, including security settings Two or three platform owners
Alpha Access to all data sources, create and edit charts and dashboards, no security settings Too broad for anyone in a multi-department deployment
Gamma Read charts and dashboards for data sources granted separately; no SQL Lab The right base for viewers and most builders
sql_lab SQL Lab access, layered on another role Analysts who write SQL
Public Applied to unauthenticated visitors Empty, unless you publish public dashboards deliberately

Alpha is the problem. It carries all_datasource_access, so an Alpha user sees every dataset in every database connected to Superset. The moment two departments share an instance, Alpha is a policy violation waiting to be discovered. The design below uses it for nobody.

The model: capability roles plus data-domain roles

Split roles into two families and assign at least one from each.

Capability roles: what a person can do

Role Built from Adds Removes
cap_viewer Gamma Nothing can_export on Chart and Dashboard if exports are restricted
cap_explorer Gamma can_explore on Superset, can_write on Chart (own charts), can_read on Dataset Dashboard editing
cap_builder Gamma Everything in explorer, plus can_write on Dashboard, can_read on CssTemplate
cap_analyst Gamma + sql_lab SQL Lab, can_sql_json, can_csv, saved queries Dashboard editing unless also a builder
cap_curator cap_builder can_write on Dataset, can_read on Database (to create virtual datasets and manage the catalogue) Database connection editing
cap_admin Admin

None of these grant any data. A user with only cap_builder can create a dashboard but has no datasets to put on it.

Data-domain roles: what a person can see

One role per data domain, granting schema_access on the schemas that belong to it, and nothing else:

Role Grants
data_finance schema_access on [warehouse].[finance], schema_access on [warehouse].[finance_marts]
data_sales schema_access on [warehouse].[sales]
data_ops schema_access on [warehouse].[operations]
data_hr_restricted datasource_access on specific HR datasets, not the schema, because the schema also contains payroll
data_shared schema_access on [warehouse].[shared_dims], the calendar, geography and product dimensions everyone needs

Prefer schema access. A new dataset created in the finance schema is visible to data_finance holders automatically, which is what the finance team expects. Use dataset-level access only when a schema contains a mix of sensitivities, and treat that as a signal to split the schema in the warehouse.

Putting them together

A finance analyst holds cap_analyst, data_finance, data_shared. A sales operations dashboard builder holds cap_builder, data_sales, data_ops, data_shared. A board member holds cap_viewer and data_finance. Adding a marketing department is one new data role and a mapping in the identity provider. Adding a new capability (say, alert creation) is one change to one capability role.

With five capability roles and ten domains you have fifteen roles, not fifty, and every one has a name a security reviewer can read.

Designing this for your actual org chart?

The capability/data-domain split above is the pattern; mapping it onto your specific teams, data domains, and SSO groups is the part that takes a workshop, not a blog post. A Superset Architecture Review does that mapping.

Book a Superset Architecture Review

Dashboard permissions

Data-domain roles decide which datasets a user can query. They do not by themselves control which dashboards appear in the list. Two mechanisms do:

Default behaviour. A user sees a dashboard if they have datasource access to every dataset on it (or the dashboard is published and they have access to at least one chart’s data, depending on version). This falls out of the data roles and is usually enough.

Dashboard RBAC. With the DASHBOARD_RBAC feature flag on, a dashboard owner can attach roles to a dashboard under its properties. Users in those roles can view the dashboard even if they lack datasource access to its datasets, for that dashboard only. This is how you publish an executive summary built on restricted data to a wider audience without granting them the underlying tables.

python
FEATURE_FLAGS = {
    "DASHBOARD_RBAC": True,
}

Use it deliberately. Because it bypasses dataset permissions, a dashboard with an over-broad role attached is a data exposure. Reserve the right to attach roles to curators, and review attachments quarterly (see the checks section).

Row-level security

RBAC decides which tables. Row-level security decides which rows. Under Settings, Row Level Security, a rule attaches a SQL clause to datasets for roles:

  • Regular rules add a filter for the listed roles: region = 'EMEA' on the sales datasets for data_sales_emea.
  • Base rules apply to everyone except the listed roles, which is how you filter by default and exempt a headquarters role.
  • Clauses can use Jinja: owner_email = '{{ current_username() }}' for per-user filtering, with ENABLE_TEMPLATE_PROCESSING on.

RLS composes with the role design cleanly: data_sales grants the tables, data_sales_emea (a role with no permissions at all, used only as an RLS handle) restricts the rows. For customer-facing multi-tenant deployments the considerations are different and covered in multi-tenant RLS for embedded Superset.

SQL Lab and database-level controls

SQL Lab lets analysts run arbitrary SQL, subject to the schemas their roles grant. Controls that matter at scale:

  • Per database connection, under Advanced, SQL Lab: expose in SQL Lab (off for databases nobody should query ad hoc), allow DML (off everywhere except a sandbox), allow file uploads (off).
  • SQL_MAX_ROW and DISPLAY_MAX_ROW in config, and per-database query timeouts, so one analyst cannot take down the warehouse.
  • schema_access is what limits which schemas appear in SQL Lab’s schema dropdown; database_access alone exposes every schema, so grant schemas not databases.
  • Consider user impersonation on the connection for warehouses that support it (Snowflake, BigQuery, Postgres roles). Superset then runs the query as the logged-in user, and the warehouse’s own audit log attributes it.

Feeding roles from SSO

Hand-assigning fifteen roles to five hundred people is where good designs die. Every capability and data role should map from a group in the identity provider through AUTH_ROLES_MAPPING, with AUTH_ROLES_SYNC_AT_LOGIN = True so that a group change takes effect at the next login. The Okta guide shows the mechanics; the design here is what the mapping targets:

python
AUTH_ROLES_MAPPING = {
    "bi-viewers": ["cap_viewer", "data_shared"],
    "bi-builders": ["cap_builder", "data_shared"],
    "bi-analysts": ["cap_analyst", "data_shared"],
    "finance-all": ["data_finance"],
    "sales-all": ["data_sales"],
    "sales-emea": ["data_sales_emea"],
    "bi-platform-admins": ["cap_admin"],
}

Group membership is now managed by the people who manage groups, with the identity provider’s approval workflow and audit trail, and Superset simply reflects it.

Roles as code

Clicking permissions into fifteen roles is error-prone and unrepeatable. Two ways to define them in configuration:

FAB_ROLES lets you declare roles as lists of regex pairs over view menu and permission names:

python
FAB_ROLES = {
    "cap_viewer": [
        [".*", "can_read"],
        ["Dashboard", "can_read"],
        ["Chart", "can_read"],
        ["Superset", "can_dashboard"],
        ["Superset", "can_slice"],
    ],
}

Roles declared here are created and kept in sync on superset init, and the config file becomes the policy document.

A provisioning script using the security manager’s API (add_role, find_permission_view_menu, add_permission_role) for the data-domain roles, run as part of deployment, reading the schema list from a YAML file. This is what we do for larger deployments because schema permissions are data-driven and a regex over [warehouse].[finance.*] is fragile.

Either way, keep the definition in version control and treat a role change like a code change: reviewed, tested in staging, deployed.

The checks that keep it honest

A role model drifts. These queries against the metadata database, run monthly, catch the usual drift:

  • Nobody holds Alpha. SELECT u.username FROM ab_user u JOIN ab_user_role ur ON u.id = ur.user_id JOIN ab_role r ON r.id = ur.role_id WHERE r.name = 'Alpha'. Expected: empty.
  • Admin count is small and known. Same query for Admin. Expected: the platform owners, nobody else.
  • No role grants `all_datasource_access` except Admin. Check ab_permission_view_role joined to permissions.
  • Every dataset’s schema is covered by exactly one data role, so that a new dataset is not accidentally visible through two domains.
  • Dashboard RBAC attachments list, with owners, for review.
  • Users with no data role (they can log in and see nothing), which usually means an SSO group mapping is missing.
  • Users not seen for 90 days, for deactivation.

Put the results in a dashboard. Superset is quite good at those.

Where to start if you have the everyone-is-Alpha deployment

  • Inventory users, roles and dataset permissions from the metadata database.
  • Define the data domains from the warehouse schemas, and the capability roles from what people actually do (most are viewers).
  • Create the new roles alongside the old ones, map SSO groups, and give a pilot department the new roles in addition to their old ones.
  • Verify the pilot sees what they should and nothing more, then remove their old roles.
  • Repeat per department. Remove Alpha last, once the last department is migrated.

Expect two to four weeks for a five-hundred-user deployment, most of it in conversations about who should see what, which is the conversation the deployment should have had at the start.

Frequently Asked Questions

Why is giving everyone the Alpha role a problem?

Alpha grants access to every dataset in the deployment, including ones added after the role was assigned, plus the ability to edit them. At fifty users that is untidy. At five hundred it means no one can answer the question an auditor actually asks, which is who can see a given table and why. The role model below exists so that question has an answer that does not depend on memory.

How many roles does a 500-user Superset deployment need?

Fewer than most teams expect, if the roles compose. Splitting them into capability roles (what a person can do) and data-domain roles (what a person can see) means a new department is one new data role rather than a full set of duplicates. A deployment of this size typically lands between twelve and twenty roles in total, not one per team.

Does row-level security replace role-based access control?

No, it narrows it. RBAC decides whether someone reaches a dataset at all; RLS decides which rows they see once they are there. They are designed to work together: a data-domain role grants the tables, and an RLS rule attached to a permission-free handle role restricts the rows. Using RLS alone to do both leaves the dataset itself reachable.

Can Superset roles be assigned automatically from Okta or Entra ID?

Yes, and they should be at this scale. Map every capability and data role to a group in the identity provider through AUTH_ROLES_MAPPING, and set AUTH_ROLES_SYNC_AT_LOGIN = True so a group change takes effect at the user’s next login. Access is then granted and revoked where the rest of your joiners-and-leavers process already lives.

Build It With Andolasoft

Role design is the second thing we look at in a Superset Architecture Review, after authentication: who has Alpha, how datasets are granted, whether RLS is doing what people think it is, and what the drift checks find. It is a fixed-scope, five-day review with a written report. If you would rather the role model were maintained for you, alongside patching and monitoring, that is Managed Support.

If you are looking at an everyone-is-Alpha deployment and an audit date, talk to us. We will map your current roles and dataset grants, show you where the real exposure is, and give you a fixed-scope plan to the model described above.

Multi-Tenant Row-Level Security in Embedded Superset: Three Patterns and When Each Fails

Embedding a Superset dashboard in your product is a few hours of work. Making sure that dashboard shows tenant A only tenant A’s rows, for every chart, every filter, every drill-down and every cached result, is the part that decides whether embedded analytics is a feature or an incident.Superset supports three distinct approaches. Teams often pick one because it was the first that worked, without knowing what it does not protect against. This post describes each, shows the configuration, and is specific about where it breaks. It ends with a test plan, because tenant isolation is one of the few things in analytics that you should prove rather than assume.

The setting

A typical embedded setup: your application authenticates its users, then asks Superset for a short-lived guest token scoped to a dashboard. The browser loads the dashboard in an iframe through the Embedded SDK, presenting that token. Superset runs the dashboard’s queries as a guest user with a fixed role and returns the results.

The tenant boundary can be enforced in three places along that path:

  • In the guest token, as RLS clauses your backend attaches when it mints the token.
  • In Superset’s RLS rules, attached to the guest role and parameterised with Jinja from the token’s user attributes.
  • In the data layer, by giving each tenant its own schema or database so that no single query can span tenants.

They are not mutually exclusive. The strongest deployments combine 1 or 2 with 3.

Pattern 1: RLS clauses in the guest token

When your backend requests a guest token, it can include rls clauses. Superset appends each clause to the WHERE of every query the dashboard runs, optionally restricted to one dataset.

json
POST /api/v1/security/guest_token/
{
  "user": { "username": "tenant-acme-user-42", "first_name": "Acme", "last_name": "User" },
  "resources": [{ "type": "dashboard", "id": "c1a4e2d7-...-dashboard-uuid" }],
  "rls": [
    { "dataset": 17, "clause": "tenant_id = 'acme'" },
    { "dataset": 23, "clause": "account_id IN (SELECT id FROM accounts WHERE tenant = 'acme')" }
  ]
}

The clause is SQL. Superset wraps it in parentheses and ANDs it with whatever the chart’s own filters produce.

Why teams like it: all tenant logic lives in your backend, next to the code that already knows who the user is. No Superset configuration per tenant. Adding a tenant is nothing.

Where it fails:

  • You are building SQL from application state. If tenant_id comes from anywhere the user can influence, or is interpolated without care, this is a SQL injection surface inside your security boundary. Derive the tenant identifier from your own authenticated session, never from a request parameter, and use a strict allow-list format (an integer, a UUID) rather than free text.
  • Clauses without a `dataset` apply to every dataset on the dashboard. If one dataset has no tenant_id column, that chart errors. Worse, if a dataset has a column by that name with a different meaning, the filter silently does the wrong thing. Always scope clauses to dataset IDs, and treat a new dataset on the dashboard as a change that needs a matching clause.
  • Virtual datasets. For a dataset defined by a SQL query, the RLS clause is applied to the outer query, not inside the subquery. If the inner SQL pre-aggregates across tenants, the outer filter cannot un-aggregate it. Virtual datasets used in embedded dashboards must keep tenant identifiers at their output grain.
  • Chart-level SQL escape hatches. Custom SQL in ad-hoc metrics or filters still runs inside the same query and is still wrapped by the RLS clause, so this is safe. But SQL Lab and the chart data API used outside the dashboard are not covered by a dashboard-scoped token. Make sure the guest role cannot reach them.
  • Token lifetime. Guest tokens default to a five-minute expiry. A token minted for tenant A and leaked is valid for tenant A’s data for that long. Keep the expiry short and mint tokens on demand, not in advance.

Pattern 2: Superset RLS rules with Jinja

Superset has its own row-level security feature under Settings, Row Level Security. A rule attaches a SQL clause to one or more datasets for one or more roles. The clause can use Jinja, and in an embedded context the interesting variables are the guest user’s attributes:

sql
tenant_id = (
  SELECT tenant_id FROM app_users WHERE username = '{{ current_username() }}'
)

Assign the rule to the guest role (GUEST_ROLE_NAME in superset_config.py), and every query the guest user runs against that dataset is filtered. Your backend puts the tenant-bearing username into the token and does nothing else. Requires ENABLE_TEMPLATE_PROCESSING = True.

Why teams like it: the tenant logic is declared once, in Superset, alongside the datasets it protects. Auditors can read it. A new dashboard on an existing dataset is protected automatically.

Where it fails:

  • The guest role is one role. Every embedded user shares it, so RLS rules cannot distinguish tenants by role. All the discrimination has to come through Jinja and the username, which means the username format is now part of your security model. Document it and validate it when minting tokens.
  • Jinja is a template, not a parameter. current_username() is interpolated into SQL. Superset’s built-in functions are safe, but a rule that interpolates anything user-controlled, such as url_param, is not. Never use url_param or dashboard filter values in an RLS rule; they are user input.
  • Every dataset needs a rule. A dataset added to an embedded dashboard without a rule is fully exposed. There is no default-deny. Put a check in your release process, or a script that lists embedded dashboards’ datasets without RLS rules.
  • Performance. The subquery runs inside every chart query. Index app_users.username, or precompute a mapping table, or the “secure” version of the dashboard is the slow one.
  • The lookup table is inside the analytics database. Your tenant mapping now has to be replicated into the warehouse and kept current. A stale mapping is either an outage or a leak, depending on the direction of the staleness.

Picked a pattern? Now stress-test the failure mode

Each RLS pattern above fails differently at real tenant counts and under caching. A Superset Embedding Feasibility Review pressure-tests your chosen pattern against your actual tenant model before it ships.

Request an Embedding Feasibility Review

Pattern 3: Physical isolation per tenant

Give each tenant its own schema (or database) and point tenant-specific datasets at them. Superset then cannot write a cross-tenant query because no single table contains two tenants.

Two ways to make this work with one set of dashboards:

  • Schema-per-tenant with dataset templating. Superset datasets and SQL templates can use Jinja for the schema name, but the guest token cannot switch schemas on its own. In practice this means one dashboard copy per tenant, generated by script from a template dashboard, each pointing at tenant datasets. The export and import API makes this manageable up to a few hundred tenants.
  • Database-per-tenant with connection-level identity. A separate Superset database connection per tenant, with credentials that can only see that tenant’s data. Combine with the dashboard-per-tenant approach above.

Why teams like it: the boundary is enforced by the database’s own permissions, not by SQL rewriting. A bug in Superset, a missed rule or a bad clause cannot cross it. Compliance conversations are shorter.

Where it fails:

  • Operational load. Hundreds of schemas, database connections and dashboard copies are a real maintenance burden. Dashboard changes must be re-propagated. Metadata database size grows with tenant count.
  • Cross-tenant features are impossible by construction. Benchmarking a tenant against an anonymised peer group, a common premium feature, cannot be done inside the isolated model. You end up building a separate aggregated dataset anyway, with Pattern 1 or 2 protecting it.
  • Provisioning is code you must write and maintain. New tenant means new schema, new connection, new dashboard import, all automated, all tested.

Choosing

Situation Recommendation
Tens to thousands of tenants, shared tables, moderate sensitivity Pattern 1 (token RLS), scoped to dataset IDs, with tenant IDs taken only from the server-side session
Dashboards change often, several teams add datasets Pattern 2 (Superset RLS) for default coverage, plus a release check that every embedded dataset has a rule
Regulated data, contractual isolation requirements, tenant count in the tens Pattern 3 (schema or database per tenant), with Pattern 1 as a second layer
Premium cross-tenant benchmarking Pattern 3 for tenant data, Pattern 1 on a separate pre-aggregated, anonymised dataset

Whichever you choose, apply two rules universally. First, the guest role has the minimum permissions: read on the specific dashboards, nothing on SQL Lab, nothing on the chart or dataset list endpoints. Second, dashboard-level native filters and URL parameters are user experience, never security; a filter can be removed by the user, an RLS clause cannot.

Caching

Superset caches chart data. If two tenants’ queries produce the same cache key, tenant B receives tenant A’s cached rows. Superset includes the query object, which contains the RLS clauses and the guest user context, in the cache key, so the standard patterns above are safe by design. Two things can still go wrong:

  • A custom cache key function or a plugin that builds its own cache key and omits the RLS context.
  • A caching layer outside Superset (a CDN, a reverse proxy cache) in front of the chart data endpoint.

Include a caching test in the plan below rather than reasoning about it.

The test plan

Isolation is testable. Run this before launch and on every change to dashboards, datasets, RLS rules or the token-minting code.

  • Two tenants, known data. Seed tenant A and tenant B with distinctive values you can search for in responses.
  • Positive test. Mint a token for A, load the dashboard, capture every chart data response, assert every row belongs to A.
  • Cross-tenant attempt. Using A’s token, call the chart data endpoint directly with a modified form data payload that removes or alters the tenant filter. Assert the response still contains only A’s rows.
  • Dataset coverage. List every dataset referenced by every embedded dashboard. Assert each has an RLS clause in the token or an RLS rule in Superset.
  • Cache test. Load the dashboard as A, then immediately as B, with identical filters. Assert B’s responses contain none of A’s distinctive values.
  • Expiry. Use an expired token and assert a 401, not stale data.
  • Role scope. Using a guest token, call the SQL Lab, dataset list and chart list APIs. Assert 403 on all of them.

Automate it. A guest token, a couple of HTTP calls and a few assertions fit in any CI pipeline.

Frequently Asked Questions

What is the safest way to isolate tenants in embedded Superset?

Physical isolation, a separate database or schema per tenant, is the strongest guarantee because a mistake in a filter cannot cross a boundary that does not exist. It is also the most expensive to operate. Guest-token RLS clauses are the usual choice for SaaS at scale, and they are safe when the token is minted server-side from the authenticated session and never accepts a tenant id from the browser.

Can one Superset instance safely serve many customers?

Yes, and most embedded deployments do. The conditions are that the tenant identifier comes from your own session rather than from the client, that every dataset in the embedded dashboard carries the isolating clause, and that the test plan below is run on every release. A single dataset without the clause is all it takes for the guarantee to fail.

Does query caching leak data between tenants?

It will if the cache key does not include the tenant. Superset caches on the query and its parameters, so two tenants issuing the same logical query can share a cache entry unless the RLS clause is part of the key. This is the single most commonly missed step in a multi-tenant embed and it does not show up in functional testing.

Do we need a separate database per tenant?

Usually not. A shared database with a tenant column and enforced RLS is adequate for most SaaS products and far cheaper to run. Separate databases earn their cost when a contract, a regulator or a data-residency requirement demands it, or when tenant data volumes differ enough that they need separate tuning.

Build It With Andolasoft

Tenant isolation is one part of an embedded implementation, alongside theming, the token service, SDK integration and event wiring. Our embedded analytics implementation package delivers all of it as a fixed scope, and it starts with a feasibility review in which the tenancy model is the first thing we look at. If your dashboards are already embedded and you want the isolation checked, the Architecture Review covers RLS and role design. The broader controls are in our post on governance and security for Superset deployments.

If you are embedding Superset into a product and want the tenancy model checked before it ships, talk to us. We will review your token service, datasets and cache configuration against the failure modes above, and give you a written plan to close any gaps.

Superset Plugin Compatibility: What Breaks Between 3.x and 4.x and How to Migrate a Plugin

A custom chart plugin is compiled into the Superset frontend bundle. That is what makes it fast and first-class, and it is also why an upgrade can break it: the plugin is built against one version of @superset-ui/core and @superset-ui/chart-controls, and after the upgrade it runs against another. Minor releases rarely matter. Major ones, and the 3.x to 4.x jump in particular, changed enough that most plugins need at least a rebuild and some need real changes.This post is the checklist we work through when we carry a plugin across that line. It assumes you have a plugin structured the way the build guide describes: index.ts, buildQuery.ts, controlPanel.ts, transformProps.ts and a React component, registered in MainPreset.js.

One caveat up front. The exact list of breaking changes depends on the two versions you are moving between. The authoritative source is UPDATING.md in the Superset repository, read for every release between your current tag and your target. What follows is the set that has affected plugins we maintain.

Why plugins break at all

Three kinds of coupling exist between a plugin and the Superset it runs in:

  • Package coupling. The plugin imports from @superset-ui/core, @superset-ui/chart-controls and often @superset-ui/plugin-chart-echarts. These packages live inside the Superset monorepo and are versioned with it. A type that was exported in one version may be renamed or removed in the next.
  • Behavioural coupling. The plugin relies on how Superset builds queries, passes form data, applies filters and handles cross-filter events. Feature flags that flip their default between versions change this behaviour without changing any API.
  • Toolchain coupling. The plugin is compiled by Superset’s webpack, TypeScript and Babel configuration, on the Node version the Superset frontend requires. A stricter TypeScript setting or a newer Node can fail a build that has not changed.

Most upgrade pain comes from the second and third, because they do not show up as type errors.

What changed between 3.x and 4.x that plugin authors notice

Node and build tooling

The 4.x frontend requires Node 18. If your plugin’s CI still runs Node 16, the first symptom is a build failure with no obvious connection to your code. Align the Node version in your plugin repository, your Dockerfile and your CI with superset-frontend/package.json at the target tag.

TypeScript configuration also tightened across the 3.x line. Code that compiled with implicit any or loose null checks can start failing. Fix the types rather than loosening the config; the Superset build uses its own settings, not yours.

Peer dependency pins

Every plugin declares @superset-ui/core and @superset-ui/chart-controls as peer dependencies pinned to specific versions. Those versions moved through the 3.x and 4.x releases. A mismatch produces one of two failures:

  • The package manager resolves two copies of @superset-ui/core into the bundle. Registries and theme contexts are singletons, so the plugin’s copy cannot see the charts, colour schemes or theme registered by Superset’s copy. The symptom is an error along the lines of “theme is undefined” or a chart that never appears in the picker.
  • A type or helper the plugin imports no longer exists at the new version, which is a compile error and at least easy to find.

Update the pins to match the target Superset before doing anything else.

Time range and axis controls

The GENERIC_CHART_AXES feature flag, which lets any column be the x-axis and moves the time range into ad-hoc filters, became the default behaviour in the 3.x line and is the only behaviour in 4.x. Plugins built for 2.x or early 3.x often still use the legacy time section:

typescript
controlPanelSections: [
  sections.legacyRegularTime,
  // ...
]

This still compiles, and for regular (non-time-series) charts it still works. For time-series plugins it produces a control panel that does not match the rest of the product: users expect a time column and a time grain as controls, and the time range as a filter. Move to the generic axis pattern used by the ECharts time-series plugins: an x_axis control, a time_grain_sqla control, and no legacy time section. Your buildQuery then reads formData.x_axis and includes it in columns rather than assuming a __timestamp column.

Filter box removal

Superset 4.0 removed the legacy filter box chart. Plugins are rarely affected directly, but dashboards that used filter boxes to drive your chart now drive it through native filters, which arrive in extra_form_data on the query object. If your buildQuery manipulated extras or filters by hand, check that native filter values still reach the query. The buildQueryContext helper handles this correctly; hand-rolled query construction sometimes does not.

Cross-filter behaviours

Cross-filtering matured across 3.x and is on by default in 4.x. A plugin that declares Behavior.InteractiveChart is expected to emit and respond to cross-filters properly. Two things to check:

  • Emitting. The plugin should call setDataMask with both extraFormData.filters and filterState.value when the user selects an element, and clear both on deselect. Plugins that set only one of these leave the dashboard in an inconsistent state.
  • Receiving. When another chart filters this one, the filter arrives through the normal query path. Verify the chart re-queries rather than showing stale data.

If the plugin cannot participate in cross-filtering, remove Behavior.InteractiveChart from its metadata so the dashboard does not offer the option.

Theme tokens and styling

Superset’s theme object gained and reorganised tokens through 3.x. Plugins that reach into theme.colors.* or theme.gridUnit should be checked against the theme type exported by the target @superset-ui/core. Hard-coded colours keep working but look wrong in a customised deployment, which is the moment stakeholders notice.

Note for readers on the 5.x line: Superset 5 replaced the theme system with Ant Design 5 design tokens and a JSON theme format. That is a larger change than anything in 3.x to 4.x and deserves its own migration pass.

Content Security Policy

4.0 turned on Flask-Talisman and its Content Security Policy by default. Plugins that load scripts, fonts or images from external domains, for example a map tile server or a CDN-hosted library, are blocked unless TALISMAN_CONFIG allows those origins. This is a deployment change rather than a plugin change, but the plugin author is usually the one who has to explain the blank map.

Legacy visualisation migrations

4.0 completed the migration of several legacy charts to their ECharts replacements and removed the old implementations. This matters to plugin authors in one specific case: if your plugin extended or copied code from a legacy chart, the shared code it borrowed may be gone. Search your plugin for imports from legacy-plugin-chart-* packages.

Got more than one custom plugin to check?

Auditing every plugin against this list scales badly past two or three. A Plugin Scoping Call gets you a migration estimate across your whole plugin set, not just one at a time.

Book a Plugin Scoping Call

How to find out which of these affect you

Do this before touching any code.

  • Read `UPDATING.md` from your current tag to the target tag. Note every entry that mentions the frontend, feature flags, @superset-ui, or chart behaviour.
  • Diff the feature flag defaults between the two versions in superset/config.py. Any flag that flipped and touches charts or dashboards is a behavioural change your plugin will see.
  • Check the two package versions of @superset-ui/core and @superset-ui/chart-controls in superset-frontend/package.json at both tags, and read the changelogs for those packages.
  • Grep your plugin for the things listed above: legacyRegularTime, __timestamp, setDataMask, theme.colors, hard-coded colours, external URLs, and any import from a legacy- package.

You now have a list. Usually it is short.

Migration sequence

Work in this order; each step gives you a stable checkpoint.

  • Branch the plugin and name the branch for the target Superset version.
  • Bump the peer dependencies and dev dependencies to the target versions. Set Node to the target version.
  • Build the plugin alone (npm run build). Fix compile and type errors. This clears the package coupling.
  • Link it into a Superset checkout at the target tag and run the dev server. Open the chart in Explore. This surfaces registry and theme problems immediately.
  • Work through the behavioural list: time controls, filters, cross-filters, CSP. Test each in a dashboard, not only in Explore, because filters and cross-filters only exist on dashboards.
  • Restore or re-record thumbnails if the chart’s appearance changed.
  • Run the tests and update fixtures for any changed ChartProps shape.
  • Build the production image from the target tag with the plugin included, deploy to staging, and load every saved chart that uses the plugin. A saved chart carries the form data it was created with; charts saved under the old control panel are where migration bugs hide.

Budget half a day for a minor version and one to two days for a major one, assuming a single plugin of moderate complexity. Multiply for plugins that render maps or use WebGL.

Tests that make the next upgrade cheaper

  • Golden `ChartProps` fixtures. Save a real chartProps object from the running product for each chart configuration you support and assert transformProps output against it. When the shape changes, the diff tells you exactly what moved.
  • Saved-chart smoke test. A script that lists every saved chart with your viz_type and loads each one’s data endpoint. Run it against staging after every upgrade.
  • Control panel snapshot. A test that renders the control panel config and snapshots the control names. A rename in sharedControls shows up here instead of in production.

Keeping the fork thin

The lines you maintain inside the Superset repository should be two: the dependency in superset-frontend/package.json and the registration in MainPreset.js. Every upgrade is a rebase of those two lines onto the new tag. If your fork has grown beyond that, the upgrade cost is no longer about the plugin, and it is worth moving the extra code into the plugin package or a separate extension before the next major version.

Frequently Asked Questions

Will my Superset 3.x plugin work on 4.x without any changes?

Sometimes, but do not plan for it. A plugin that only uses stable @superset-ui/core exports and no time-range controls often survives untouched. Anything that uses the legacy time section, the filter box, hard-coded colours or the older cross-filter callbacks will need work. The only reliable answer comes from building the plugin against the target version and reading the errors.

What is the most common cause of a plugin breaking after a Superset upgrade?

Two copies of @superset-ui/core in the same bundle. Registries and theme contexts are singletons, so when the plugin’s copy differs from Superset’s copy, the plugin cannot see the charts, colour schemes or theme that Superset registered. It usually surfaces as “theme is undefined” or a chart that never appears in the picker. Align the peer dependency pins before investigating anything else.

The filter box was removed in 4.x. Do I have to rewrite my plugin?

Only if your plugin depended on it. The filter box was a chart type, not a plugin API, so most custom charts are unaffected. What does change is the surrounding dashboard: filters now come from native dashboard filters and cross-filters, so a plugin that read filter state the old way needs updating to the current hooks.

How long does a 3.x to 4.x plugin migration usually take?

For a single plugin with current dependency pins and no legacy time controls, a day is realistic, most of it spent on the build tooling and the test pass. Plugins that span more than one major version, borrow from legacy chart code, or have no tests take substantially longer, and the estimate is worth getting before the upgrade is scheduled rather than after.

Build It With Andolasoft

If the plugin was written by someone who has left, if it borrows from legacy chart code, or if the upgrade spans more than one major version, the cheapest first step is a scoping conversation rather than a spike. Our Plugin Scoping Call is free and 45 minutes; you leave with a written estimate of the migration effort. The wider service is described on the Superset plugin development page, and if the upgrade itself is the worry, the Architecture Review covers upgrade readiness for the whole deployment.

If a Superset upgrade is already on the calendar and the custom charts are the unknown, book a Plugin Scoping Call. We will review the plugins you have, tell you which of the changes above apply to each, and give you a written migration estimate before you commit to an upgrade date.

How to Set Up Okta Single Sign-On for Apache Superset

Password logins are the first thing an auditor asks about and the last thing anyone wants to manage by hand. If your team already uses Okta for everything else, Superset should not be the exception: one identity, one place to offboard, and roles that follow the person rather than the login.

Apache Superset supports this out of the box through Flask-AppBuilder (FAB), the framework Superset is built on. The pieces are all there. What is missing is a single, current walkthrough that covers the Okta side, the Superset side, the role mapping, and the handful of reverse-proxy and cookie settings that turn a working configuration into a redirect loop. This is that walkthrough.

By the end, you will have:

  • Okta as the only login path for Superset, with the local password form gone.
  • Users created automatically on first login, with their Okta groups mapped to Superset roles.
  • Role changes in Okta reflected in Superset at next login.
  • A troubleshooting table for the errors this setup produces in practice.

The configuration shown here applies to Superset 2.1 through 4.x. The examples were written against Superset 4.x with the Flask-AppBuilder 4 series it ships with.

How Superset authentication actually works

Superset does not implement authentication itself. FAB does, through its SecurityManager, and Superset extends that with SupersetSecurityManager. FAB supports five authentication types, selected with AUTH_TYPE in superset_config.py:

AUTH_TYPE What it does Typical use
AUTH_DB Username and password stored in Superset’s metadata database Default, small teams, demos
AUTH_LDAP Bind against an LDAP or Active Directory server On-premises corporate directories
AUTH_OAUTH OAuth 2.0 / OpenID Connect against one or more providers Okta, Azure AD, Google, Keycloak, Auth0
AUTH_OID Legacy OpenID 2.0 Rarely used today
AUTH_REMOTE_USER Trust a header set by an upstream proxy Behind an SSO-terminating gateway

Okta is an AUTH_OAUTH provider. Under the hood, FAB uses the Authlib client library to run the authorization-code flow, fetch the user’s profile from the provider’s userinfo endpoint, and hand the result to a method called oauth_user_info. FAB ships a handler for Okta, so a standard setup works without a custom security manager — though as Step 2 explains, there is one good reason to write one anyway.

Prerequisites

  • Admin access to your Okta org, or someone who has it and twenty minutes.
  • A running Superset instance reachable over HTTPS on a stable hostname. OAuth callbacks to localhost work for testing; callbacks to plain HTTP in production do not.
  • The ability to edit superset_config.py and restart Superset.
  • Authlib installed in the Superset environment. The official Docker image includes it. For a pip install, run pip install authlib if python -c "import authlib" fails.
  • A short list of Okta groups you want to map to Superset roles. Three is a good start: administrators, analysts who can write SQL, and viewers.

Step 1: Create the Okta application integration

In the Okta Admin Console:

  1. Go to Applications, then Applications, and click Create App Integration.
  2. Choose OIDC – OpenID Connect as the sign-in method and Web Application as the application type.
  3. Name it something your users will recognise, for example “Superset BI”.
  4. Under Grant type, leave Authorization Code ticked. Superset does not need implicit or hybrid flows.
  5. Set the Sign-in redirect URI to https://superset.example.com/oauth-authorized/okta. The last path segment must match the provider name you will use in superset_config.py, which in this guide is okta.
  6. Set the Sign-out redirect URI to https://superset.example.com/login/.
  7. Under Assignments, assign the groups that should be allowed to log in. Anyone not assigned will be refused by Okta before Superset ever sees them, which is the behaviour you want.
  8. Save, then copy the Client ID and Client secret from the General tab.

Choose the authorization server and expose groups

Okta has two kinds of authorization servers. The org authorization server at https://<your-org>.okta.com issues tokens for Okta’s own APIs and does not let you add custom claims. The default custom authorization server at https://<your-org>.okta.com/oauth2/default does, and it is what you need if you want group-based role mapping. Every URL in this guide uses the custom server.

To make Okta include the user’s groups in the response Superset reads:

  1. Go to Security, then API, open the default authorization server, and select the Claims tab.
  2. Click Add Claim. Name it groups, set the value type to Groups, filter Matches regex with the value .*, and include it in Any scope.
  3. Add the claim for both the ID Token and the Access Token, with type Always. FAB does not read the ID token; it calls Okta’s userinfo endpoint with the access token and maps roles from that payload. Configuring only the ID token is the most common reason group mapping silently does nothing.
  4. On the Scopes tab, confirm a groups scope exists, or add one. Superset will request it.

The regex .* returns every group the user belongs to. In large orgs, that can be a long list. Filter by a prefix such as ^superset_ if you want to keep the token small; everything else in this guide still works.

Step 2: Configure Superset

Add the following to superset_config.py. Keep the client secret out of the file and read it from the environment or your secrets manager.

python
import os
from flask_appbuilder.security.manager import AUTH_OAUTH

OKTA_BASE_URL = os.environ["OKTA_BASE_URL"]  # e.g. https://acme.okta.com
OKTA_ISSUER = f"{OKTA_BASE_URL}/oauth2/default"

AUTH_TYPE = AUTH_OAUTH

OAUTH_PROVIDERS = [
    {
        "name": "okta",
        "icon": "fa-circle-o",
        "token_key": "access_token",
        "remote_app": {
            "client_id": os.environ["OKTA_CLIENT_ID"],
            "client_secret": os.environ["OKTA_CLIENT_SECRET"],
            "api_base_url": f"{OKTA_ISSUER}/v1/",
            "client_kwargs": {"scope": "openid profile email groups"},
            "server_metadata_url": f"{OKTA_ISSUER}/.well-known/openid-configuration",
            "access_token_url": f"{OKTA_ISSUER}/v1/token",
            "authorize_url": f"{OKTA_ISSUER}/v1/authorize",
            "jwks_uri": f"{OKTA_ISSUER}/v1/keys",
        },
    }
]

# Create a Superset user the first time someone signs in through Okta.
AUTH_USER_REGISTRATION = True

# Role for users whose groups match nothing in AUTH_ROLES_MAPPING.
AUTH_USER_REGISTRATION_ROLE = "Gamma"

# Okta group name -> list of Superset roles.
AUTH_ROLES_MAPPING = {
    "superset_admins": ["Admin"],
    "superset_analysts": ["Alpha", "sql_lab"],
    "superset_viewers": ["Gamma"],
}

# Re-evaluate roles from the groups claim on every login, so a change in
# Okta takes effect the next time the user signs in.
AUTH_ROLES_SYNC_AT_LOGIN = True

What each block does:

  • name is the provider key. It appears in the login button and, more importantly, in the callback path /oauth-authorized/okta. It must match the redirect URI you registered in Okta.
  • server_metadata_url lets Authlib discover the token endpoint, signing keys and supported scopes from Okta’s OpenID configuration document. The explicit access_token_url, authorize_url and jwks_uri entries are belt and braces; they let the flow keep working if discovery is ever blocked by an egress firewall.
  • api_base_url is where FAB fetches the user profile from. FAB’s built-in Okta handler calls userinfo relative to this URL, which resolves to https://acme.okta.com/oauth2/default/v1/userinfo.
  • client_kwargs.scope must include groups, or the claim you created in Step 1 will never arrive.
  • AUTH_USER_REGISTRATION is what turns a successful Okta login into a Superset user record. Without it, only users you created by hand can log in.
  • AUTH_ROLES_MAPPING keys are Okta group names, exactly as they appear in the token. Values are lists of Superset role names. Admin, Alpha, Gamma, sql_lab and Public are the roles Superset creates by default; any custom role you have created works too.
  • AUTH_ROLES_SYNC_AT_LOGIN is the setting most people miss. Without it, roles are assigned once, at registration, and removing someone from an Okta group has no effect on what they can see in Superset. Note the flip side: with sync on, Okta is the only source of truth, so a role you grant by hand in Superset’s UI is wiped at that user’s next login.

When you need a custom security manager

FAB’s built-in Okta handler works, but it derives the username from the token’s sub claim and prefixes it, so your user list fills up with entries like okta_00u1a2b3c4d5e6f7g8h9. That is unpleasant to administer and impossible to eyeball against an audit request. Overriding oauth_user_info is also the fix if your Okta org names the groups claim differently, nests it, or you want usernames to be email addresses regardless of what preferred_username says:

python
import logging

from superset.security import SupersetSecurityManager

log = logging.getLogger(__name__)


class OktaSecurityManager(SupersetSecurityManager):
    def oauth_user_info(self, provider, response=None):
        if provider != "okta":
            return super().oauth_user_info(provider, response)

        me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo").json()

        # Logs which claims arrived without dumping personal data.
        log.debug("Okta userinfo claims: %s", sorted(me.keys()))

        return {
            "username": me["email"].lower(),
            "first_name": me.get("given_name", ""),
            "last_name": me.get("family_name", ""),
            "email": me["email"].lower(),
            "role_keys": me.get("superset_roles", me.get("groups", [])),
        }


# Also in superset_config.py, after the class is importable:
CUSTOM_SECURITY_MANAGER = OktaSecurityManager

The role_keys list is what FAB matches against AUTH_ROLES_MAPPING. Using the email address as the username is a deliberate choice: it is stable, unique, and survives a rename in Okta better than preferred_username does. Pick one convention before the first login, because changing it later creates duplicate user records.

That debug line is also your diagnostic for Step 1: if groups is missing from the logged claim list, the problem is the Okta claim configuration, not Superset.

Step 3: Settings that matter behind a reverse proxy

Almost every production Superset sits behind a load balancer, an ingress controller, or an Nginx proxy that terminates TLS. That changes what Superset thinks its own URL is, and OAuth is unforgiving about URLs.

python
from datetime import timedelta

# Trust X-Forwarded-* headers from the proxy so Superset builds https:// URLs.
ENABLE_PROXY_FIX = True
PROXY_FIX_CONFIG = {"x_for": 1, "x_proto": 1, "x_host": 1, "x_port": 1, "x_prefix": 1}

# Cookies must be sent on the callback from Okta.
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"  # "Strict" breaks the OAuth callback

# Log people out after a working day; Okta deactivation does not end live sessions.
PERMANENT_SESSION_LIFETIME = timedelta(hours=10)

Three points worth underlining:

  • Without ENABLE_PROXY_FIX, Superset sees the request as plain HTTP and constructs a redirect_uri starting with http://. Okta compares it to the https:// value you registered and rejects the login with a redirect URI mismatch.
  • SESSION_COOKIE_SAMESITE = "Strict" looks like the secure choice, and it is the one that breaks OAuth. The callback from Okta is a cross-site navigation. With Strict, the browser withholds the session cookie, Superset cannot find the state it stored before redirecting, and you get a mismatching_state error. Use Lax.
  • Deactivating someone in Okta stops new logins immediately but does not touch sessions Superset has already issued. PERMANENT_SESSION_LIFETIME bounds how long that window can be. For sensitive data, make it shorter.

Step 4: Restart and test

Restart Superset. The login page now shows a single “Sign in with Okta” button and no username or password fields.

  1. Sign in with a user who is in superset_admins. You should land on the welcome page with the Admin menu visible.
  2. Go to Settings, then List Users, and confirm the user was created with the expected username, email, and roles.
  3. Sign in as a user in superset_viewers from a private window. Confirm they see dashboards they are permitted to see and nothing else. Row-level security rules, if you have them, still apply exactly as before; SSO changes how people authenticate, not what the roles allow.
  4. Move that user into superset_analysts in Okta, sign them out and back in, and confirm SQL Lab appears. This proves AUTH_ROLES_SYNC_AT_LOGIN is working.
  5. Remove the user from every Superset group in Okta and sign in again. They should now hold only the AUTH_USER_REGISTRATION_ROLE. If you would rather they be refused entirely, remove them from the Okta application assignment instead.

Keep a break-glass path. Switching to AUTH_OAUTH removes the password form, so if Okta is unavailable, nobody can log in. Document the rollback: a copy of the previous superset_config.py with AUTH_DB, a known-good admin account in the metadata database, and the restart command. Test it once before you need it.

Troubleshooting

Symptom Likely cause Fix
Okta shows “The redirect_uri parameter must be a Login redirect URI” The callback URL Superset sent does not match Okta exactly Check the provider name, the scheme (http vs https), and trailing slashes. Enable ENABLE_PROXY_FIX behind a proxy.
mismatching_state: CSRF Warning! State not equal in request and response Session cookie not sent on the callback Set SESSION_COOKIE_SAMESITE = "Lax" and make sure SESSION_COOKIE_SECURE matches the scheme in use.
Login succeeds, but every user gets the Gamma role only Groups claim missing from the userinfo response Confirm the groups claim is configured for the Access Token as well as the ID Token, the groups scope is requested, and you are not using the org authorization server URLs.
“Access is Denied” after signing in User created without roles, or registration disabled Set AUTH_USER_REGISTRATION = True and a valid AUTH_USER_REGISTRATION_ROLE.
invalid_client in Superset logs Wrong client ID or secret, or the app is not a Web Application type Re-copy the credentials; recreate the integration as an OIDC Web Application if needed.
Usernames look like okta_00u1a2b3... FAB’s built-in handler derives the username from the sub claim Add the custom security manager from Step 2 before the first production login.
Two accounts for the same person Username convention changed between logins Pick one of preferred_username or email in oauth_user_info and keep it. Merge or delete the duplicate in List Users.
Roles do not update when Okta groups change Sync disabled Set AUTH_ROLES_SYNC_AT_LOGIN = True and have the user sign out and in.
Login button missing; password form still shown Config not loaded Confirm SUPERSET_CONFIG_PATH points to the file, or the file is on PYTHONPATH, and that Superset was actually restarted.

To see exactly what Okta returned, run Superset with debug logging for a single login using the log.debug line in the custom security manager above. If you need the claim values and not just their names, log the full payload once and remove it immediately afterwards; the response contains personal data.

Operational notes

  • Onboarding is now an Okta task. Add the person to the right group and assign them the application. There is nothing to do in Superset.
  • Offboarding is two steps. Deactivate in Okta, then either wait for PERMANENT_SESSION_LIFETIME to expire or, for immediate effect, set the user to inactive in Superset’s List Users.
  • Role grants must happen in Okta. With AUTH_ROLES_SYNC_AT_LOGIN enabled, editing a user’s roles in the Superset UI lasts only until their next login. Change the Okta group instead.
  • Service accounts and API clients that authenticate with a username and password against Superset’s REST API will stop working under AUTH_OAUTH. Move them to a token-based approach or keep a separate, restricted provider for them.
  • Multiple providers are allowed. OAUTH_PROVIDERS is a list. Some teams keep Okta for staff and a second provider for contractors or an embedded-analytics tenant.
  • Audit trail. Superset’s own logs record the username on each request. With SSO, those usernames are guaranteed to be real identities, which is what your auditor was after in the first place.

Where this fits in a hardened deployment

SSO is one control in a production Superset setup. It sits alongside role design, row-level security, network exposure, metadata database backups, and a patching cadence. If you are putting Superset in front of a wider audience, or an audit is on the calendar, it is worth reviewing all of those together rather than one at a time.

Our Superset Architecture Review is a fixed-scope, five-day review of exactly that: authentication and SSO, RBAC and RLS, caching and async queries, upgrade readiness and dashboard performance, delivered as a written report with prioritised fixes. If you would rather someone else kept the platform patched and monitored, that is what Managed Support is for.

For the wider set of controls, see our earlier post on data governance and security best practices for Superset deployments.

How to Build a Custom Chart Plugin for Apache Superset 4.x

Apache Superset ships with more than fifty chart types, and for most dashboards that is plenty. Then someone asks for a bullet chart against a target band, a Sankey with a custom node order, a map layer the built-in deck.gl charts do not offer, or a KPI tile that colours itself against three thresholds instead of one. At that point you have two options: bend an existing chart until it almost fits, or write a plugin.Plugins are how Superset itself is built. Every chart in the Explore view, from the humble table to the ECharts time series, is a plugin registered through the same ChartPlugin API you are about to use. Nothing about a custom plugin is second-class. It gets the same query layer, the same control panel framework, the same dashboard filters and cross-filters, and the same theming.

This guide builds a small but complete plugin for Superset 4.x. The chart is deliberately simple, a bar per category with a configurable highlight threshold, so that the plugin mechanics stay in focus. Swap the rendering for ECharts, D3, or deck.gl once the plumbing is in place.

What a plugin is made of

A Superset chart plugin is an npm package that exports a class extending ChartPlugin from @superset-ui/core. The class wires together five things:

Piece File Job
Metadata index.ts Name, description, thumbnail, category, tags, and behaviours shown in the chart picker
Query builder buildQuery.ts Turns the form data from the control panel into one or more query objects for Superset’s backend
Control panel controlPanel.ts Declares which controls appear in Explore and how they are grouped
Props transformer transformProps.ts Converts raw query results and form data into the props your component needs
Component HelloChart.tsx The React component that draws the chart

The flow at runtime is: the user changes a control, Superset runs buildQuery, sends the query to the backend, receives rows, calls transformProps with those rows plus the form data, and renders your component with the result. Controls flagged as renderTrigger skip the query and go straight to transformProps, which is how colour and label changes stay instant.

Prerequisites

  • Node.js at the version your Superset release pins. Read the engines field in superset-frontend/package.json at the tag you deploy rather than trusting a blog post: the 4.x line started on Node 18 and later releases accept Node 20 as well. Use nvm or fnm; a mismatched Node version is the most common cause of a build that fails for no obvious reason.
  • A checkout of the Superset repository at the exact tag you run in production, for example git clone --branch 4.1.1 https://github.com/apache/superset.git. You will run the frontend dev server from it and, later, build the production image from it.
  • A running Superset backend to develop against. The repo’s docker-compose setup works, or a local superset run -p 8088 --with-threads --reload --debugger.
  • Working knowledge of React and TypeScript. Plugins are TypeScript by default and there is no reason to fight that.

Step 1: Scaffold the plugin

Superset maintains a Yeoman generator that produces a working plugin skeleton. Install it and run it in a new directory next to, not inside, your Superset checkout:

bash
npm install -g yo @superset-ui/generator-superset

mkdir superset-plugin-chart-hello
cd superset-plugin-chart-hello
yo @superset-ui/superset

Answer the prompts: choose Chart plugin, accept the package name, add a one-line description, and pick Regular as the chart type (choose Time-series if your chart has a time axis; it changes the default controls). The generator writes a package.json, TypeScript config, Jest setup, a src/ folder with the five files above, and a src/images/thumbnail.png placeholder.

If the published generator lags behind the Superset version you are targeting, run it from your checkout instead:

bash
cd superset/superset-frontend/packages/generator-superset
npm install
npm link
cd ../../../../superset-plugin-chart-hello
yo @superset-ui/superset

Open package.json and check the peerDependencies. The generator pins @superset-ui/core and @superset-ui/chart-controls to the versions in the checkout you ran it from. These must match the Superset you deploy, or the plugin will compile against one API and run against another.

Step 2: The five files

The generated code works as-is. The point of walking through each file is to know what to change when your chart needs something different.

index.ts: metadata and wiring

typescript
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';

export default class HelloChartPlugin extends ChartPlugin {
  constructor() {
    const metadata = new ChartMetadata({
      name: t('Hello Chart'),
      description: t('One bar per category, with bars above a threshold highlighted.'),
      thumbnail,
      category: t('Custom'),
      tags: [t('Comparison'), t('Custom')],
    });

    super({
      buildQuery,
      controlPanel,
      loadChart: () => import('./HelloChart'),
      metadata,
      transformProps,
    });
  }
}

loadChart is a dynamic import so the component is code-split and only downloaded when someone opens a dashboard that uses it. Wrap every user-visible string in t() so it goes through Superset’s translation layer.

Note what is not here: a behaviors array. Adding Behavior.InteractiveChart tells Superset the chart emits cross-filters, and it does put cross-filter controls in the UI, but the affordance does nothing until your component actually calls setDataMask, which Superset passes in through chartProps.hooks. Declaring the behaviour before you wire the hook produces a menu item that silently fails. Add the behaviour and the hook together, or neither. Dashboard native filters apply to your chart either way; they arrive as part of the query, not through this flag.

buildQuery.ts: from form data to a query

typescript
import { buildQueryContext, QueryFormData } from '@superset-ui/core';

export default function buildQuery(formData: QueryFormData) {
  const { cols: groupby } = formData;
  return buildQueryContext(formData, baseQueryObject => [
    {
      ...baseQueryObject,
      groupby,
    },
  ]);
}

buildQueryContext assembles a query object from the standard controls (metrics, filters, row limit, time range) and hands it to your callback. You return an array of query objects; one is normal, two or more is how charts like the big number with trendline fetch both a total and a series. The generator names the group-by control cols and maps it onto the query’s groupby. Recent Superset versions also accept columns; either works on 4.x.

controlPanel.ts: what the user can change

typescript
import { t, validateNonEmpty } from '@superset-ui/core';
import {
  ControlPanelConfig,
  sharedControls,
} from '@superset-ui/chart-controls';

const config: ControlPanelConfig = {
  controlPanelSections: [
    {
      label: t('Query'),
      expanded: true,
      controlSetRows: [
        [
          {
            name: 'cols',
            config: {
              ...sharedControls.groupby,
              label: t('Category column'),
              description: t('One bar per distinct value'),
            },
          },
        ],
        [
          {
            name: 'metrics',
            config: {
              ...sharedControls.metrics,
              validators: [validateNonEmpty],
            },
          },
        ],
        ['adhoc_filters'],
        ['row_limit'],
      ],
    },
    {
      label: t('Hello Chart options'),
      expanded: true,
      controlSetRows: [
        [
          {
            name: 'threshold',
            config: {
              type: 'TextControl',
              isInt: true,
              default: 0,
              renderTrigger: true,
              label: t('Highlight threshold'),
              description: t('Bars at or above this value are highlighted'),
            },
          },
        ],
        [
          {
            name: 'highlight_color',
            config: {
              type: 'ColorPickerControl',
              default: { r: 26, g: 169, b: 202, a: 1 },
              renderTrigger: true,
              label: t('Highlight colour'),
            },
          },
        ],
      ],
    },
  ],
};

export default config;

Two things to notice. First, sharedControls gives you the same metric, group-by and filter controls every built-in chart uses, so the Explore experience is consistent. Second, renderTrigger: true on the threshold and colour controls means changing them re-renders without re-querying the database. Anything that only affects drawing should be a render trigger; anything that changes what data comes back should not.

The generator’s template may also import a sections helper and open the panel with a time section such as sections.legacyRegularTime. Whether that export exists depends on the release you pin: the time controls were reworked across the 4.x line as the generic chart axes behaviour became the default. Check what your @superset-ui/chart-controls actually exports before importing it, because a missing export takes out the whole control panel rather than one control. The config above needs no time section at all; adhoc_filters covers time filtering.

Control names are snake_case here. They arrive in transformProps as camelCase (highlight_color becomes highlightColor), because ChartProps runs the form data through a camelCase conversion and keeps the original on rawFormData. This conversion is the single most common source of “my control value is undefined”.

transformProps.ts: shaping data for the component

typescript
import { ChartProps, DataRecord } from '@superset-ui/core';

export interface HelloChartProps {
  width: number;
  height: number;
  data: DataRecord[];
  categoryColumn: string;
  metricLabel: string;
  threshold: number;
  highlightColor: string;
}

export default function transformProps(chartProps: ChartProps): HelloChartProps {
  const { width, height, formData, queriesData } = chartProps;
  const { cols, metrics, threshold, highlightColor } = formData;

  const data = (queriesData[0]?.data ?? []) as DataRecord[];
  const metric = metrics?.[0];
  const metricLabel =
    typeof metric === 'string' ? metric : metric?.label ?? 'value';
  const rgba = highlightColor
    ? `rgba(${highlightColor.r}, ${highlightColor.g}, ${highlightColor.b}, ${highlightColor.a})`
    : '#1aa9ca';

  return {
    width,
    height,
    data,
    categoryColumn: Array.isArray(cols) ? cols[0] : cols,
    metricLabel,
    threshold: Number(threshold) || 0,
    highlightColor: rgba,
  };
}

queriesData is an array with one entry per query object you returned from buildQuery. Each entry has a data array of row objects keyed by column or metric label. Metrics can be plain strings (saved metrics) or ad-hoc metric objects with a label; handle both. Keep this function pure and cheap; it runs on every render trigger.

HelloChart.tsx: the component

tsx
import React from 'react';
import { styled } from '@superset-ui/core';
import { HelloChartProps } from './transformProps';

const Wrapper = styled.div<{ height: number; width: number }>`
  height: ${({ height }) => height}px;
  width: ${({ width }) => width}px;
  box-sizing: border-box;
  display: flex;
  align-items: stretch;
  gap: 6px;
  padding: 8px;
  font-family: ${({ theme }) => theme.typography.families.sansSerif};
`;

/* One column per row: bar area on top, label pinned underneath. */
const Column = styled.div`
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
`;

/* Gives the bar a definite height to take a percentage of. */
const BarArea = styled.div`
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: flex-end;
`;

const Bar = styled.div<{ pct: number; color: string }>`
  width: 100%;
  height: ${({ pct }) => pct}%;
  background-color: ${({ color }) => color};
  border-radius: 2px 2px 0 0;
`;

const Label = styled.div`
  flex: 0 0 auto;
  padding-top: 4px;
  font-size: 11px;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
`;

export default function HelloChart({
  width,
  height,
  data,
  categoryColumn,
  metricLabel,
  threshold,
  highlightColor,
}: HelloChartProps) {
  const max = Math.max(1, ...data.map(row => Number(row[metricLabel]) || 0));

  return (
    <Wrapper width={width} height={height}>
      {data.map(row => {
        const value = Number(row[metricLabel]) || 0;
        const pct = (value / max) * 100;
        const hot = value >= threshold;
        const category = String(row[categoryColumn]);
        return (
          <Column key={category} title={`${category}: ${value}`}>
            <BarArea>
              <Bar pct={pct} color={hot ? highlightColor : '#ccc'} />
            </BarArea>
            <Label>{category}</Label>
          </Column>
        );
      })}
    </Wrapper>
  );
}

The nesting is worth a moment, because it is the part people get wrong first. A percentage height only resolves against a parent with a definite height. Wrapper gets one from the pixel height Superset hands it, and BarArea gets one from being a flex child of Wrapper, so height: ${pct}% on the bar works. Put that percentage directly inside an auto-height div and every bar collapses to nothing.

Otherwise this is plain HTML and CSS on purpose. It has no chart library dependency, it is trivially testable, and it demonstrates the two things every component must do: fill the width and height Superset gives it, and read its theme from @superset-ui/core rather than hard-coding fonts. For anything more demanding, look at how @superset-ui/plugin-chart-echarts wraps ECharts, or how the deck.gl plugins handle WebGL. The pattern is the same; only the drawing changes.

Step 3: Run it inside Superset

The plugin has to be part of the Superset frontend bundle. Superset does carry an experimental DYNAMIC_PLUGINS feature flag that fetches a plugin from a URL at runtime, but it has stayed experimental for years and nothing in the dashboard experience is built around it, so treat compiling into the bundle as the supported path.

The package’s entry point is its build output, not its source, so build it before linking:

bash
cd superset-plugin-chart-hello
npm i --force
npm run build

The --force is there because the generator’s peer dependency ranges rarely resolve cleanly against a single Superset checkout. Then link the package into the Superset frontend:

bash
cd ../superset/superset-frontend
npm i -S ../../superset-plugin-chart-hello

Then open src/visualizations/presets/MainPreset.js and add the plugin alongside the built-ins:

javascript
import HelloChartPlugin from 'superset-plugin-chart-hello';

// ... inside the plugins array passed to super()
new HelloChartPlugin().configure({ key: 'hello_chart' }),

The key is the chart’s viz_type. It is stored on every saved chart that uses the plugin, so choose it once and never change it.

Now run three processes: the backend, the Superset dev server, and a watch build for the plugin.

bash
# terminal 1, from the repo root with your virtualenv active
superset run -p 8088 --with-threads --reload --debugger

# terminal 2
cd superset/superset-frontend
npm run dev-server

# terminal 3, in the plugin directory
npm run dev

That third terminal is not optional. Superset resolves the linked package through its main field, which points at compiled output, so editing src/HelloChart.tsx changes nothing until the plugin is rebuilt. npm run dev is the generator’s watch build; check the scripts block in your package.json if the name differs. Without it you will edit code for twenty minutes and conclude that hot reloading is broken.

Open http://localhost:9000, create a chart, and search for “Hello Chart” in the picker. Pick a dataset, a category column and a metric, and you should see bars. Change the threshold and watch the bars recolour without a spinner, which confirms the render trigger is wired correctly.

If the dev server does not pick up a rebuilt plugin, restart it; watching across a symlink is occasionally flaky on Windows and in Docker volumes.

Step 4: Getting it into production

Because the plugin is compiled into the bundle, production means building Superset’s frontend with your plugin included. The official apache/superset image does not contain the frontend source, so you build from the repository at the tag you deploy:

  • In your Superset checkout, add the plugin dependency to superset-frontend/package.json and the registration line in MainPreset.js. Use a git URL or a private registry entry, not the relative path you developed against: the plugin directory sits outside the Docker build context, so a relative dependency fails at npm ci rather than merely being untidy. Commit both changes on a branch named for the Superset version, for example 4.1.1-acme.
  • Build the image from the repo root: docker build -t registry.example.com/superset:4.1.1-acme .. The Dockerfile’s frontend build stage runs npm ci and the frontend build, which now includes your plugin.
  • Deploy that image in place of the official one. Nothing else changes: same configuration, same metadata database, same Helm chart or compose file.

When you upgrade Superset, rebase the branch onto the new tag, bump the plugin’s @superset-ui/* peer dependencies to match, rebuild, and run the plugin’s tests. Budget half a day for a minor version and more for a major one; the ChartPlugin API is stable, but control-panel helpers and theme tokens do move.

Step 5: Tests worth writing

The generator sets up Jest. Three tests pay for themselves immediately:

  • transformProps with a realistic ChartProps fixture: assert the metric label resolution for both string and ad-hoc metrics, the camelCase control names, and the empty queriesData case.
  • buildQuery with sample form data: assert the query object contains the expected groupby and metrics, so a control rename does not silently produce an empty query.
  • The component with React Testing Library: render with three rows, assert three bars, and assert the highlighted count for a given threshold.

Superset’s frontend also has a Storybook (superset-frontend/storybook). Adding a story for your plugin gives designers and stakeholders a place to look at it without a running backend.

Pitfalls that cost time

  • Peer dependency drift. @superset-ui/core in your plugin must be the version the Superset frontend uses. Two copies of the library in one bundle produce baffling errors about themes or registries being undefined.
  • Forgetting the plugin’s watch build. The linked package serves compiled output, so source edits are invisible until it rebuilds.
  • camelCase vs snake_case. Controls are defined in snake_case and read in camelCase. Log formData once in transformProps if a value is missing.
  • Forgetting renderTrigger. Every cosmetic control without it forces a database round trip.
  • Declaring behaviours you have not implemented. Behavior.InteractiveChart without a setDataMask call gives users a cross-filter option that does nothing.
  • Percentage sizing inside auto-height containers. Give the parent a definite height or the chart renders empty at full data.
  • Hard-coded colours and fonts. Read them from the theme so the chart looks right in a themed or white-labelled Superset.
  • Changing the viz_type key. Saved charts reference it. Renaming it orphans every chart built on the plugin.
  • Ignoring width and height. Superset tells your component its size. A chart that sizes itself overflows dashboard grid cells.
  • Forking Superset core. Put chart logic in the plugin package, not in the Superset repo. The only lines you should be maintaining in the fork are the dependency and the registration.

What we have built this way

The mechanics above are the same ones behind the plugins we have written up on this blog: a dual-axis line chart for Superset 4 that plots two measures on independent axes and takes part in cross-filtering, and a multi-threshold heatmap for Superset 4.1 that colours cells against several hard limits instead of one gradient. Both started as exactly the skeleton in this post.

If you have a chart in mind and want to know what it would take, our Plugin Scoping Call is a free 45-minute session with a plugin engineer. You describe the visualisation, we walk through the data shape and interactions, and within 48 hours you get a written scope, effort estimate, and timeline you can act on with or without us. The wider service is described on our Superset plugin development page.