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.
- Embed API key path — BFF calls
POST /public/embed/v1/sessionswithec_live_…; browser usesfetchSession/session-url. - This JWT path — BFF only signs a JWT; the browser SDK exchanges that JWT with Embedded Canvas for a guest session.
Part I — Embedded Canvas setup
Control plane · do this first
EC-1. Sign in and open Analytics
- Sign in at
https://embeddedcanvas.com→ open Analytics.
EC-2. Step 1 — Start analytics runtime
- Provision runtime until Status: active → Launch analytics builder.
EC-3. Step 2 — Configure workspace
- Connect warehouse → set Tenant column → Save.
- Confirm two test tenant ids with different row counts.
EC-4. Build dashboard + native filters
- Create a dataset/chart that changes between tenants → publish the dashboard.
-
Add native filters in the builder for every column you will drive from your app UI later
(tutorial examples:
city,service_line, optionaltech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them viafilters.extra(see Host-owned filters). - 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
- Builder → Embed dashboard → Allowed Domains:
http://localhost:5173. - Analytics Step 3 → register → iframe parent origins:
http://localhost:5173. - Copy the control-plane embed token UUID — you will paste it into Vite
.envasVITE_*in Part II.
EC-6. Step 4 — Create signing key
- 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).
- Paste into server env (JWT BFF
.envnext tojwt-server.mjs) asSIGNING_KEY. -
Note the control-plane dashboard row id → server env
DASHBOARD_ROW_ID(JWTsub). 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 anec_live_…API key.
Values checklist
| Value | Where you get it | Where you put it |
|---|---|---|
EMBED_TOKEN | Analytics Step 3 — dashboard embed token UUID | Client (and server mint body on BFF) |
CUSTOMER_ID | Tenant id from your warehouse / auth | Server derives; client may demo via input |
SIGNING_KEY | Analytics Step 4 — workspace signing key (hex) | Server .env only |
DASHBOARD_ROW_ID | Control-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.
| Credential | Looks like | Put it here | Never 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 |
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
- Scaffold Vite React + install SDKs
- Create
jwt-server.mjs(the JWT mint BFF) - Create project-root
.env(paste tokens) - Add the
jwtscript topackage.json - Create / update
vite.config.js(proxy) - Replace
src/App.jsx - Run JWT BFF + Vite and open the app
- Optional — hosted iframe with
?jwt= - Host-owned filters (non-native UI)
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.
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).
- Create the file:
New-Item jwt-server.mjs(PowerShell) or New File in your editor. - 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.
- Create
.env. - Paste the template; replace placeholders from Part I:
SIGNING_KEY← Step 4 hex signing keyDASHBOARD_ROW_ID← control-plane dashboard row id (JWTsubfrom Analytics Step 3 / Connect — notec_live_…)TEST_CUSTOMER_ID/VITE_TEST_CUSTOMER_ID← same warehouse tenantVITE_EMBED_TOKEN← Step 3 embed token UUID
- Confirm
.envis 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
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.
/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
- Open
src/App.jsxand 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>
);
}
- Confirm
VITE_EMBED_TOKEN/VITE_TEST_CUSTOMER_IDmatch Part I. Restart Vite after editing.env.
7. Run JWT BFF + Vite and open the app
- Terminal A:
npm run jwt→ expectJWT mint listening on http://localhost:3090. - Terminal B:
npm run dev→ openhttp://localhost:5173. - You should see the dashboard for
VITE_TEST_CUSTOMER_ID.
POST /api/analytics/jwt fails.
If only the JWT BFF is running, there is no page to open.
Quick troubleshooting
Access-Control-Allow-Origin/ CORS on/embed/superset/…/session→ leaveVITE_API_BASE_URLempty, add the/embed/supersetVite proxy (step 5), restartnpm run dev. Confirm iframe parent origins includehttp://localhost:5173.Configure .env (SIGNING_KEY…)/ mint returns 500 aboutec_live_→DASHBOARD_ROW_IDmust be the dashboard row id (JWTsubfrom Analytics Step 3/Connect), not an embed API key.SIGNING_KEYmust be the real hex key from Step 4 (not aPASTE_placeholder).401on/embed/superset/…/session→ almost always wrongSIGNING_KEYand/or wrongDASHBOARD_ROW_ID(decode your JWTsub— it must not start withec_live_).403→customerIdmust match the JWT claim; allowlists missinghttp://localhost:5173.404/ HTML on JWT route → proxy missing or Vite not restarted.- Blank iframe → dual allowlist (runtime Allowed Domains + control-plane iframe parent origins).
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)
- Open the dashboard in the analytics builder (Part I).
- Add a native filter for each column your host UI will drive
(examples used in these tutorials:
city,service_line). -
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). - Publish / save the dashboard.
-
Optional white-label: hide the native filter bar in the builder embed settings / keep
layoutMode: "chromeless"(Part II samples already do this).
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
- DevTools → Network →
POST …/session(or your BFF path) → confirm JSON includesfilters.extra. - Type a value you know exists for the current tenant → Apply → chart numbers should change.
- Clear inputs → Apply → broader (or unfiltered) result for that tenant.
filters.extra at mint time.
Both are required for white-label filter UX on the SDK + session route path.
customerId must match the JWT customer_id claim — mismatch returns 403.