You shipped the dashboard. Your client sees a blank screen.
Embedding Metabase looks easy. Drop an iframe. Done.
Then production hits. Your client opens the dashboard and nothing loads. The console shows a CORS error. The token expired. The iframe renders Metabase chrome on top of your carefully branded application. A user from Client A sees Client B’s financials.
None of these problems surface in development. Every one of them surfaced for us when we embedded Metabase into the Crossroads Health Provider Portal. This guide covers every lesson we learned shipping embedded analytics to production.
Static embedding: when it works and when it fails
Metabase offers two embedding modes. Understanding when to use each saves you weeks of rework.
Static embedding generates a public URL for a dashboard or question. Anyone with the link can view it. No authentication. No parameter filtering. No per-user data scoping. You enable it by toggling “Enable sharing” on a dashboard and copying the link.
Use static embedding for internal dashboards displayed on office monitors. Use it for public-facing metrics where everyone sees the same numbers. Do not use it for client-facing portals. Do not use it anywhere data must scope to a logged-in user.
Interactive embedding signs a JWT that identifies the viewer and passes parameters Metabase uses to filter queries. The dashboard renders inside an iframe on your application. Each user sees only the rows their parameters allow. Interactive embedding requires Metabase Enterprise or the open-source edition with a JWT signing endpoint you control.
We used interactive embedding for Crossroads Health. Every healthcare provider logged into the portal and saw their facility’s data. No cross-client leakage. No manual filter configuration per user.
Getting Metabase ready for embedding
Run Metabase behind HTTPS. Browsers block iframes that mix HTTP and HTTPS origins. Your application server and your Metabase instance both need TLS. Use a reverse proxy if Metabase itself does not terminate TLS.
docker run -d \
--name metabase \
-p 3000:3000 \
-e MB_JETTY_PORT=3000 \
-e MB_DB_TYPE=postgres \
-e MB_DB_DBNAME=metabase \
-e MB_DB_PORT=5432 \
-e MB_DB_USER=metabase \
-e MB_DB_PASS=your_db_password \
-e MB_DB_HOST=your_postgres_host \
-e MB_ENABLE_EMBEDDING=true \
-e MB_EMBEDDING_SECRET_KEY=your-256-bit-secret \
-e MB_SITE_URL=https://metabase.yourdomain.com \
metabase/metabase:latestFour environment variables matter for embedding:
MB_ENABLE_EMBEDDING=trueturns on the feature.MB_EMBEDDING_SECRET_KEYis the key you sign JWTs with. Generate something cryptographically random. Rotate it on a schedule.MB_SITE_URLtells Metabase its own origin. Browsers check this against the iframe’ssrcattribute. A mismatch breaks loading.MB_JETTY_PORTis only relevant in Docker. Metabase listens on this port inside the container.
The MB_EMBEDDING_SECRET_KEY must stay secret. Store it in a vault. Inject it at runtime. Never hardcode it. Never commit it.
Your application server needs access to the same secret so it can sign tokens Metabase trusts. We use environment variables and inject them through our deployment pipeline.
JWT signing: the code you actually need
Metabase expects a JWT with a payload that identifies the dashboard, the user, and any parameters that scope the data. You sign that token on your application server and pass it through the iframe URL.
Here is the Python version we run in production at Crossroads Health:
import json
import time
import jwt
METABASE_SITE_URL = "https://metabase.yourdomain.com"
METABASE_SECRET_KEY = "your-256-bit-secret-key"
def generate_metabase_token(dashboard_id, user, params):
payload = {
"resource": {"dashboard": dashboard_id},
"params": params,
"exp": int(time.time()) + 600,
}
token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256")
return (
f"{METABASE_SITE_URL}/embed/dashboard/{token}"
"#bordered=false&titled=false&theme=night"
)The resource field tells Metabase which dashboard to render. params holds the filter values that scope data per user. exp sets a 10-minute token lifetime. Short lifetimes limit the window for token replay. Regenerate tokens on each page load or on a refresh interval.
The URL fragment hides Metabase chrome. bordered=false removes the outer frame. titled=false suppresses the dashboard title bar. theme=night applies the dark theme if your app uses one. Combine these with CSS overrides and your users never know Metabase is behind the curtain.
Here is the JavaScript equivalent. Use this if your backend runs Node:
import jwt from "jsonwebtoken";
const METABASE_SITE_URL = "https://metabase.yourdomain.com";
const METABASE_SECRET_KEY = "your-256-bit-secret-key";
function generateMetabaseToken(dashboardId, user, params) {
const payload = {
resource: { dashboard: dashboardId },
params,
exp: Math.floor(Date.now() / 1000) + 600,
};
const token = jwt.sign(payload, METABASE_SECRET_KEY, { algorithm: "HS256" });
return (
`${METABASE_SITE_URL}/embed/dashboard/${token}` +
"#bordered=false&titled=false&theme=night"
);
}Both snippets use HS256. Metabase supports RS256 if you prefer asymmetric signing. The tradeoff: HS256 requires a shared secret. RS256 requires key distribution but lets you keep the signing key private. For most deployments, HS256 with proper secret management is simpler and sufficient.
Row-level security: one database, many tenants
You have one Metabase instance. One database. Several clients, each with rows in the same tables. Without row-level security, every client sees every row.
Metabase handles this with locked parameters. You define a parameter on the dashboard, mark it locked, and set its value in the JWT payload. The user cannot see or change the parameter. Metabase injects it into every SQL query on the dashboard.
Suppose your claims table has a facility_id column. You want each healthcare provider to see claims from their facility only. First, create a SQL question with a parameterized filter:
SELECT
claim_date,
claim_amount,
claim_status
FROM claims
WHERE facility_id = {{facility_id}}
AND claim_date BETWEEN {{start_date}} AND {{end_date}}Attach this question to your dashboard. Add a dashboard-level filter called facility_id. Mark it locked. Now your JWT payload includes the facility_id value for the logged-in user:
params = {
"facility_id": current_user.facility_id,
"start_date": request.args.get("start_date"),
"end_date": request.args.get("end_date"),
}
token = generate_metabase_token(dashboard_id, current_user, params)The user sees their facility’s claims. The facility_id parameter never appears in the UI. The start_date and end_date parameters are unlocked so the user can adjust the date range.
For multi-tenant SaaS where clients have separate databases or schemas, set the db parameter in the JWT to connect Metabase to the correct database. You need to configure each database connection in Metabase first. The JWT selects which one to use:
params = {"db": client_database_id}This approach works when you have ten or twenty databases. Past fifty, manage connections through Metabase’s API rather than the UI.
Test row-level security exhaustively. Log in as a user from Client A. Confirm you cannot access Client B’s data through any combination of parameter manipulation or URL tampering. Then test the reverse. Do this before any client sees production.
White-labeling: make Metabase disappear
Embedded Metabase ships with its own chrome, navigation, and branding. Your application has its own. The two collide.
Start with the URL fragment options: bordered=false, titled=false, theme=transparent. Then override Metabase’s CSS. Metabase lets you inject custom CSS through Admin Settings > Embedding > Appearance.
/* Hide the Metabase sidebar and top bar */
header,
nav,
[data-testid="dashboard-header"],
[data-testid="query-builder-root"] > div:first-child {
display: none !important;
}
/* Match your application's font */
body,
.dashboard,
.Card-title {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
sans-serif !important;
}
/* Override Metabase brand color with yours */
.Button--primary,
.Button--primary:hover {
background-color: #3366ff !important;
border-color: #2952cc !important;
}
/* Remove Metabase loading spinner branding */
.LogoIcon {
display: none !important;
}Metabase CSS selectors change between versions. After every upgrade, verify your overrides still work. Target data-testid attributes where possible. They are less likely to change than class names.
Replace the Metabase favicon and logo. Metabase serves these from its own assets directory. The cleanest approach: mount replacement files into the Docker container.
docker run -d \
--name metabase \
-v /path/to/your/favicon.ico:/app/frontend_client/favicon.ico \
-v /path/to/your/logo.svg:/app/frontend_client/app/assets/img/logo.svg \
metabase/metabase:latestFor the Embedded Analytics SDK (available in Metabase 50+), you get a React component that renders the dashboard without an iframe. The SDK eliminates the CSS override fragility entirely. We recommend it for new projects. For existing iframe setups, CSS overrides remain the pragmatic path.
Iframe hardening: security you must not skip
Embedding dashboards in an iframe introduces three security risks: clickjacking, content injection, and unauthorized access through the parent page.
Sandbox the iframe. The sandbox attribute restricts what the embedded content can do:
<iframe
src="{{ metabase_url }}"
sandbox="allow-scripts allow-same-origin"
style="width: 100%; height: 100%; border: none;"
title="Analytics Dashboard"
></iframe>allow-scripts lets Metabase JavaScript execute. allow-same-origin lets Metabase read its own cookies and storage. Do not add allow-top-navigation or allow-popups. Metabase does not need them.
Set Content Security Policy headers. Your application server should send these headers for pages that host embedded dashboards:
add_header Content-Security-Policy "
frame-ancestors 'self';
frame-src https://metabase.yourdomain.com;
script-src 'self' https://metabase.yourdomain.com;
" always;frame-ancestors 'self' prevents other sites from iframing your application. frame-src limits which origins can appear in iframes on your page. Lock both down.
For Apache:
Header always set Content-Security-Policy \
"frame-ancestors 'self'; frame-src https://metabase.yourdomain.com;"Prevent clickjacking. Send the X-Frame-Options header on your Metabase instance. Configure your reverse proxy:
add_header X-Frame-Options "ALLOW-FROM https://app.yourdomain.com" always;Modern browsers also respect frame-ancestors in CSP. Send both for defense in depth.
Validate parameters server-side. The JWT payload includes parameters that filter data. Never trust the client to set these. Generate tokens only on your backend after checking the user’s permissions. An attacker who modifies the iframe URL cannot access another tenant’s data because the token signature fails if the payload changes.
Performance: dashboards that load fast
Embedded dashboards make one thing immediately obvious: slow queries kill your application’s perceived performance. Users do not distinguish between your app being slow and the dashboard being slow. They close the tab.
Connection pooling. Metabase uses HikariCP for connection pooling. Tune the pool size for your workload. Each embedded dashboard session opens queries through the pool. Too few connections cause queuing. Too many overwhelm your database.
-e MB_JDBC_DATA_WAREHOUSE_MAX_CONNECTION_POOL_SIZE=15 \
-e MB_JDBC_DATA_WAREHOUSE_MINIMUM_CONNECTION_POOL_SIZE=5 \Start with a pool size equal to your expected concurrent dashboard viewers plus twenty percent. Monitor and adjust.
Materialized views. Dashboards that aggregate large tables on every load are slow by design. Create materialized views for frequently accessed aggregations:
CREATE MATERIALIZED VIEW mv_daily_claims AS
SELECT
facility_id,
claim_date,
COUNT(*) AS claim_count,
SUM(claim_amount) AS total_amount
FROM claims
GROUP BY facility_id, claim_date;
CREATE UNIQUE INDEX idx_mv_daily_claims
ON mv_daily_claims (facility_id, claim_date);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_claims;Schedule refreshes with pg_cron or a cron job on your application server. Refresh frequency depends on your data velocity. For operational dashboards, refresh every five minutes. For analytical dashboards, refresh hourly.
Metabase query cache. Enable caching in Admin Settings > Caching. Metabase caches query results and serves them to subsequent viewers with the same parameters. Set a TTL that matches your data freshness requirements:
-e MB_QUERY_CACHING_TTL_RATIO=10 \
-e MB_QUERY_CACHING_MIN_TTL=60000 \Reduce dashboard cardinality. Each chart issues a separate query. A dashboard with twenty charts fires twenty queries on every load. Merge related charts where possible. Use dashboard filters instead of per-chart filters. Cache intermediate results with saved questions that other questions reference.
Monitor slow queries. Metabase logs query durations. Ship these logs to your observability stack. Set alerts for queries exceeding five seconds. Optimize the underlying tables and indexes. Add EXPLAIN ANALYZE to your optimization workflow.
Troubleshooting: the five problems that will hit you
Blank iframe. The most common cause: HTTP/HTTPS mismatch. Your application runs on HTTPS. Your Metabase iframe points to HTTP. The browser blocks mixed content. Fix your Metabase SITE_URL to use HTTPS. Check your reverse proxy terminates TLS.
CORS errors in console. Metabase and your application run on different origins. Metabase must allow your application’s origin. Set the CORS configuration:
-e MB_EMBEDDING_APP_ORIGIN=https://app.yourdomain.comIf you serve your application from multiple origins (staging, localhost), add each one.
Expired tokens. The JWT embeds an exp claim. After expiry, Metabase rejects the token and shows an error or a blank screen. Regenerate the token on each page load. For long-lived dashboard sessions, implement a token refresh mechanism that requests a new token before expiry. A simple approach: set up a backend endpoint that returns a fresh token and call it from the parent page on an interval.
Dashboard shows Metabase login screen. Your JWT payload is missing the resource field or the secret key does not match. Verify your MB_EMBEDDING_SECRET_KEY matches the key you use to sign tokens. Check the Metabase logs for JWT validation errors.
Parameters not filtering data. The parameter name in your JWT must match the dashboard filter name exactly, including case. A facility_id parameter in the token does not match a facility_ID filter on the dashboard. If the parameter is locked and the naming is correct, check that every question on the dashboard references that parameter. Unreferenced questions ignore locked parameters.
How we shipped this at Crossroads Health
Crossroads Health processes healthcare credit balance transactions for hospital systems. Their clients received monthly PDFs and called account managers for anything in between. Forty to fifty percent of account management time went to routine status inquiries.
We deployed a self-hosted Metabase instance on CRH’s infrastructure, layered on their existing PostgreSQL databases. Everything stays in-house. Full HIPAA compliance. Full SOC2 certification. No patient data leaves their environment.
The dashboards embed into a secure, white-labeled Provider Portal. Each hospital system sees only its own facilities. Role-based access means a CFO sees different metrics than a billing analyst. No one knows Metabase is behind the curtain.
The numbers from the first quarter:
- Client status inquiry calls dropped 90%.
- Client satisfaction scores rose 45%.
- Account managers reclaimed half their working time.
- New client onboarding cut by 75%.
- Zero client departures since launch, against roughly 8% annual churn before.
See the full case study: Crossroads Health Provider Portal

When Metabase is the wrong choice
Metabase works well for SQL-accessible relational data, moderate dashboard complexity, and teams that want to ship embedded analytics without building a custom charting layer. It is not the right tool for every situation.
Grafana suits operational monitoring and time-series data. Superset handles large- scale analytical workloads with complex chart types. We compared all three in detail: Metabase vs Grafana vs Superset.
For regulated industries that need embedded analytics, we wrote a companion guide on HIPAA compliance: HIPAA-Compliant Analytics Portal.
The checklist before you go live
Before your first client logs in, verify every item on this list:
- Metabase runs behind HTTPS with a valid TLS certificate.
- Your application serves embedded pages over HTTPS.
MB_EMBEDDING_SECRET_KEYlives in a vault, not in source control.- JWT tokens expire in 10 minutes or less.
- Locked parameters scope every dashboard to the authenticated user.
- You have tested row-level security with accounts from different tenants.
- CSP headers restrict frame sources and frame ancestors.
- Custom CSS hides Metabase chrome and matches your brand.
- Connection pools are sized for expected concurrency.
- Materialized views support your slowest queries.
- Query cache TTL settings match your data freshness requirements.
- You have monitoring on query latency and error rates.
- You have tested on Chrome, Firefox, Safari, and Edge.
Embedded analytics ships trust alongside data. When a client opens your portal and sees their numbers, correctly scoped, fast, and invisible to neighbors, they trust your platform. When a blank screen greets them, they trust their Excel spreadsheet.