Embed analytics — JWT mint BFF

Alternative to the embed API key (ec_live_…) session path: your small Node BFF mints a short-lived workspace JWT with @embeddedcanvas/embed-sdk/jwt. The browser mounts with apiBaseUrl + getJwt (or a hosted iframe ?jwt=). The signing key never leaves the server.

How this differs from the embed API key BFF
  • Embed API key path — BFF calls POST /public/embed/v1/sessions with ec_live_…; browser uses fetchSession / session-url.
  • This JWT path — BFF only signs a JWT; the browser SDK exchanges that JWT with Embedded Canvas for a guest session.
Same BFF principle (secrets stay on your server). Prefer the embed API key session tutorials when you want the BFF to own the full session mint.

Part I — Embedded Canvas setup

Control plane · do this first

EC-1. Sign in and open Analytics

  1. Sign in at https://embeddedcanvas.com → open Analytics.

EC-2. Step 1 — Start analytics runtime

  1. Provision runtime until Status: active → Launch analytics builder.

EC-3. Step 2 — Configure workspace

  1. Connect warehouse → set Tenant column → Save.
  2. Confirm two test tenant ids with different row counts.

EC-4. Build dashboard + native filters

  1. Create a dataset/chart that changes between tenants → publish the dashboard.
  2. Add native filters in the builder for every column you will drive from your app UI later (tutorial examples: city, service_line, optional tech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them via filters.extra (see Host-owned filters).
  3. For a white-label product UX, hide the native filter bar in the iframe and own filters in your app UI (Part II).

EC-5. Register dashboard & dual allowlist

  1. Builder → Embed dashboard → Allowed Domains: http://localhost:5173.
  2. Analytics Step 3 → register → iframe parent origins: http://localhost:5173.
  3. Copy the control-plane embed token UUID — you will paste it into Vite .env as VITE_* in Part II.
Dual allowlist Step 3 iframe parent origins and builder Allowed Domains must both list every host origin.

EC-6. Step 4 — Create signing key

  1. Analytics → Step 4 → scroll past embed API keys → expand Alternative — workspace signing key (JWT) → Create signing key → copy the hex value once (it is not shown again).
  2. Paste into server env (JWT BFF .env next to jwt-server.mjs) as SIGNING_KEY.
  3. Note the control-plane dashboard row id → server env DASHBOARD_ROW_ID (JWT sub). Find it under Step 3 → Registered dashboards (or Step 4 → Embed URLs) — labeled Dashboard row id (JWT sub / DASHBOARD_ROW_ID). This is not the embed token UUID and not an ec_live_… API key.

Values checklist

ValueWhere you get itWhere you put it
EMBED_TOKENAnalytics Step 3 — dashboard embed token UUIDClient (and server mint body on BFF)
CUSTOMER_IDTenant id from your warehouse / authServer derives; client may demo via input
SIGNING_KEYAnalytics Step 4 — workspace signing key (hex)Server .env only
DASHBOARD_ROW_IDControl-plane dashboard row id (JWT sub)Server .env only

Where to put each credential

Signing keys mint JWTs on your server. The browser receives only the short-lived JWT + embed token.

CredentialLooks likePut it hereNever put it here
SIGNING_KEY Hex key from Analytics Step 4 Server only: JWT BFF .env next to jwt-server.mjs → passed to mintEmbedJwt({ signingKey }) Any browser code, public env vars, client SDK props
DASHBOARD_ROW_ID Control-plane dashboard id (JWT sub) Server: JWT BFF .env next to jwt-server.mjs → mintEmbedJwt({ sub }) —
EMBED_TOKEN UUID from Analytics Step 3 Browser: Vite .env as VITE_* / mountAnalyticsEmbed({ apiBaseUrl, getJwt, embedToken }) (embedToken prop or iframe path) —
Minted jwt HS256 token from your BFF Returned from BFF to browser → pass as jwt= / SDK jwt prop (short TTL) Long-lived storage; do not mint in the browser
customerId Tenant key Bound in JWT claims and SDK/query customerId (must match) Client-only values that skip your auth
No embed API key on this path JWT tutorials use the workspace signing key instead of ec_live_…. Do not mix both patterns in the same mint call unless your architecture explicitly requires it.

Part II — React + JWT mint BFF

Your laptop · follow in order

1. Scaffold Vite React + install SDKs

In a terminal, run:

npm create vite@latest my-analytics-jwt -- --template react
cd my-analytics-jwt
npm install
npm install @embeddedcanvas/embed-sdk@^1.8.3 @superset-ui/embedded-sdk

Stay in my-analytics-jwt for every remaining step.

Requirement Use Node 20+ so node --env-file=.env works. Check with node -v.

2. Create jwt-server.mjs (the JWT mint BFF)

This file does not exist yet. Create it in the project root (next to package.json).

  1. Create the file: New-Item jwt-server.mjs (PowerShell) or New File in your editor.
  2. Paste the entire contents below and save.
/**
 * JWT mint BFF — signs short-lived workspace JWTs (signing key never leaves this process).
 * Run from this folder: npm run jwt
 * (uses Node's --env-file=.env; requires Node 20+)
 */
import { mintEmbedJwt } from "@embeddedcanvas/embed-sdk/jwt";
import http from "node:http";

const SIGNING_KEY = process.env.SIGNING_KEY?.trim();
const DASHBOARD_ROW_ID = process.env.DASHBOARD_ROW_ID?.trim();
const PORT = Number(process.env.PORT || 3090);

function json(res, status, body) {
  res.writeHead(status, {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
  });
  res.end(JSON.stringify(body));
}

function configError() {
  if (!SIGNING_KEY || SIGNING_KEY.startsWith("PASTE_")) {
    return "Set SIGNING_KEY to the hex signing key from Analytics Step 4 (not a placeholder).";
  }
  if (!/^[0-9a-fA-F]+$/.test(SIGNING_KEY) || SIGNING_KEY.length < 32) {
    return "SIGNING_KEY must be the hex string from Analytics Step 4.";
  }
  if (!DASHBOARD_ROW_ID || DASHBOARD_ROW_ID.startsWith("PASTE_")) {
    return "Set DASHBOARD_ROW_ID to the control-plane dashboard row id (JWT sub).";
  }
  if (DASHBOARD_ROW_ID.startsWith("ec_live_") || DASHBOARD_ROW_ID.startsWith("ec_test_")) {
    return "DASHBOARD_ROW_ID must be the dashboard row id, not an embed API key (ec_live_…). Find it in Analytics Step 3 / Connect — JWT sub.";
  }
  return null;
}

const server = http.createServer(async (req, res) => {
  if (req.method === "OPTIONS") return json(res, 204, {});
  if (req.method === "POST" && req.url === "/api/analytics/jwt") {
    const cfgErr = configError();
    if (cfgErr) {
      return json(res, 500, { error: cfgErr });
    }
    let body = "";
    for await (const chunk of req) body += chunk;
    let customerId = process.env.TEST_CUSTOMER_ID || "CUST-1004";
    let filters;
    try {
      const parsed = body ? JSON.parse(body) : {};
      if (typeof parsed.customerId === "string" && parsed.customerId.trim()) {
        customerId = parsed.customerId.trim();
      }
      filters = parsed.filters;
    } catch {
      return json(res, 400, { error: "Invalid JSON" });
    }
    const jwt = mintEmbedJwt({
      sub: DASHBOARD_ROW_ID,
      signingKey: SIGNING_KEY,
      ttlSeconds: 300,
      customerId,
    });
    return json(res, 200, { jwt, customerId, filters: filters ?? null });
  }
  json(res, 404, { error: "Not found" });
});

server.listen(PORT, () => {
  console.log(`JWT mint listening on http://localhost:${PORT}`);
  console.log(`POST http://localhost:${PORT}/api/analytics/jwt`);
});

3. Create project-root .env (paste tokens)

Create one .env next to jwt-server.mjs. Vite only injects VITE_* into the browser.

  1. Create .env.
  2. Paste the template; replace placeholders from Part I:
    • SIGNING_KEY ← Step 4 hex signing key
    • DASHBOARD_ROW_ID ← control-plane dashboard row id (JWT sub from Analytics Step 3 / Connect — not ec_live_…)
    • TEST_CUSTOMER_ID / VITE_TEST_CUSTOMER_ID ← same warehouse tenant
    • VITE_EMBED_TOKEN ← Step 3 embed token UUID
  3. Confirm .env is in .gitignore.
# Server only — read by: npm run jwt  (node --env-file=.env jwt-server.mjs)
SIGNING_KEY=PASTE_HEX_SIGNING_KEY_FROM_STEP4
DASHBOARD_ROW_ID=PASTE_CONTROL_PLANE_DASHBOARD_ID
PORT=3090
TEST_CUSTOMER_ID=CUST-1004

# Browser — Vite injects VITE_* only
VITE_EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID
# Leave empty for local Vite proxy (avoids CORS). Direct API needs dashboard origins reflected by the control plane.
VITE_API_BASE_URL=
VITE_TEST_CUSTOMER_ID=CUST-1004
Never put SIGNING_KEY in Vite Do not create VITE_SIGNING_KEY. Only the short-lived JWT returns to the browser. customerId in the JWT claim must match the SDK customerId — mismatch → 403.
CORS note The browser SDK calls /embed/superset/…/session with your JWT. Calling https://api.embeddedcanvas.com directly from localhost fails CORS unless that origin is on the dashboard allowlist and the API reflects it. Local lab: leave VITE_API_BASE_URL empty and proxy /embed/superset through Vite (step 5).

4. Add the jwt script to package.json

Open package.json in the project root. Inside the existing "scripts" object, add the jwt line (keep dev / build / preview). Example:

File: package.json

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "jwt": "node --env-file=.env jwt-server.mjs"
  }
}

Save. From now on npm run jwt starts jwt-server.mjs with your .env.

5. Create / update vite.config.js (proxy)

Open vite.config.js if the React template created it; otherwise create it next to package.json. Proxy JWT mint to the BFF and session fetches to the control plane (same-origin → no CORS):

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api/analytics/jwt": {
        target: "http://localhost:3090",
        changeOrigin: true,
      },
      "/embed/superset": {
        target: "https://api.embeddedcanvas.com",
        changeOrigin: true,
        secure: true,
      },
    },
  },
});

After saving, restart npm run dev.

6. Replace src/App.jsx

  1. Open src/App.jsx and replace all contents with:
import { useEffect, useRef, useState } from "react";
import { mountAnalyticsEmbed } from "@embeddedcanvas/embed-sdk";

const EMBED_TOKEN = import.meta.env.VITE_EMBED_TOKEN || "YOUR_EMBED_TOKEN_UUID";
// Same-origin via Vite /embed/superset proxy (avoids CORS). Do not point at api.embeddedcanvas.com from the browser.
const API_BASE =
  (import.meta.env.VITE_API_BASE_URL || "").trim() || window.location.origin;
const tenantId = import.meta.env.VITE_TEST_CUSTOMER_ID || "CUST-1004";

async function fetchJwt(customerId) {
  const res = await fetch("/api/analytics/jwt", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ customerId }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error || "JWT mint failed");
  return data.jwt;
}

export default function App() {
  const mountRef = useRef(null);
  const controllerRef = useRef(null);
  const [city, setCity] = useState("");
  const [serviceLine, setServiceLine] = useState("");

  useEffect(() => {
    const el = mountRef.current;
    if (!el) return;
    let cancelled = false;
    mountAnalyticsEmbed({
      apiBaseUrl: API_BASE,
      embedToken: EMBED_TOKEN,
      customerId: tenantId,
      mountPoint: el,
      getJwt: () => fetchJwt(tenantId),
      layoutMode: "chromeless",
    }).then((c) => {
      if (cancelled) c.destroy();
      else controllerRef.current = c;
    });
    return () => {
      cancelled = true;
      controllerRef.current?.destroy();
    };
  }, []);

  async function applyFilters() {
    const extra = {};
    if (city.trim()) extra.city = city.trim();
    if (serviceLine.trim()) extra.service_line = serviceLine.trim();
    await controllerRef.current?.setFilters(Object.keys(extra).length ? { extra } : {});
  }

  return (
    <div>
      <p style={{ padding: "12px 12px 0", color: "#64748b", fontSize: 14 }}>
        Tenant: <code>{tenantId}</code>
      </p>
      <div style={{ display: "flex", gap: 8, padding: 12 }}>
        <input value={city} onChange={(e) => setCity(e.target.value)} placeholder="city" />
        <input value={serviceLine} onChange={(e) => setServiceLine(e.target.value)} placeholder="service_line" />
        <button type="button" onClick={applyFilters}>Apply</button>
      </div>
      <div ref={mountRef} style={{ minHeight: "70vh" }} />
    </div>
  );
}
  1. Confirm VITE_EMBED_TOKEN / VITE_TEST_CUSTOMER_ID match Part I. Restart Vite after editing .env.

7. Run JWT BFF + Vite and open the app

  1. Terminal A: npm run jwt → expect JWT mint listening on http://localhost:3090.
  2. Terminal B: npm run dev → open http://localhost:5173.
  3. You should see the dashboard for VITE_TEST_CUSTOMER_ID.
Both processes required If only Vite is running, POST /api/analytics/jwt fails. If only the JWT BFF is running, there is no page to open.

Quick troubleshooting

Suggested layout when finished:

my-analytics-jwt/
  .env                 ← SIGNING_KEY + DASHBOARD_ROW_ID + VITE_* (gitignored)
  jwt-server.mjs       ← JWT mint BFF
  package.json         ← "jwt" script
  vite.config.js       ← proxy /api/analytics/jwt → :3090
  src/App.jsx          ← mountAnalyticsEmbed + getJwt + setFilters

8. Optional — hosted iframe with ?jwt=

Same mint BFF, no SDK mount — fetch a JWT, then set iframe src:

const jwt = await fetchJwt(tenantId);
const src =
  "https://embeddedcanvas.com/embed/analytics/" +
  encodeURIComponent(EMBED_TOKEN) +
  "?customer_id=" + encodeURIComponent(tenantId) +
  "&jwt=" + encodeURIComponent(jwt) +
  "&layout=chromeless";
iframe.src = src;

Host-owned filters (non-native UI) — step by step

White-label filter UX

Native filters are the filter controls inside the analytics builder / iframe. Host-owned filters are inputs in your app (the city / service_line boxes in Part II). Your app collects values → sends them as filters.extra on session mint → Embedded Canvas applies them and syncs matching native filters. End users never need the native filter bar.

F-1. Create matching native filters in the builder (required)

  1. Open the dashboard in the analytics builder (Part I).
  2. Add a native filter for each column your host UI will drive (examples used in these tutorials: city, service_line).
  3. The filter must target the same dataset column you will send in filters.extra. Keys are matched by column name (case/spelling must match warehouse values too).
  4. Publish / save the dashboard.
  5. Optional white-label: hide the native filter bar in the builder embed settings / keep layoutMode: "chromeless" (Part II samples already do this).
If host filters do nothing Usually the native filter was never created, the column name does not match the filters.extra key, or the typed value does not exist in the data (wrong spelling/case).

F-2. Collect values in your app UI

Part II already adds simple inputs (e.g. city, service_line) and an Apply button. Those are host-owned filters — not Superset UI.

F-3. Send values on session mint as filters.extra

Your JWT mint BFF (jwt-server.mjs) must include non-empty host values under filters.extra when calling POST /public/embed/v1/sessions (directly or via your BFF):

{
  "embedToken": "…",
  "customerId": "co_alltrade",
  "filters": {
    "extra": {
      "city": "Denver",
      "service_line": "Electrical"
    }
  }
}

In Part II sample apps, the browser posts that shape to /api/analytics/session; the BFF forwards filters to Embedded Canvas. Empty strings are omitted — only filled inputs are sent.

F-4. On Apply, remint (do not only update local React/Vue state)

In the React sample, Apply calls controller.setFilters({ extra }). The SDK remints a session with Embedded Canvas using a fresh getJwt() token and filters.extra. Changing the inputs alone does not remint — you must click Apply. Native filters for those columns must exist on the dashboard (F-1). For BFF-owned session mint with filters.extra in your own POST /sessions proxy, use the embed API key session tutorials instead.

Changing an input alone does nothing until Apply remints the guest session with the new filters.extra. Charts then reload under RLS + the new filter values.

F-5. Verify

  1. DevTools → Network → POST …/session (or your BFF path) → confirm JSON includes filters.extra.
  2. Type a value you know exists for the current tenant → Apply → chart numbers should change.
  3. Clear inputs → Apply → broader (or unfiltered) result for that tenant.
Mental model Native filters = definition in the builder (which columns can be filtered). Host-owned filters = your UI + filters.extra at mint time. Both are required for white-label filter UX on the SDK + session route path.
customer_id binding SDK / query customerId must match the JWT customer_id claim — mismatch returns 403.