How tenant-scoped embeds prevent customer data leaks
Embedded analytics is one of the easiest places to leak customer data, because the iframe is rendered in the browser of someone you don't control. The only durable defense is to make the browser physically unable to ask for data that doesn't belong to it. Here is the path the request actually takes through Emban — JWT, locked filters, and a ClickHouse query that refuses to widen scope.
The naive shape that nobody should ship
The first version anyone writes looks like this:
<iframe src="https://analytics.example.com/embed?tenant=acme"></iframe>This works. It also lets any visitor open DevTools, change ?tenant=acme to ?tenant=globex, hit reload, and see Globex's revenue chart. The tenant id is being trusted from a place it shouldn't be trusted from: the URL bar.
The fix is to move the tenant id into something only the server can produce — a signed token — and to make the data layer refuse to read it from anywhere else.
Step 1 — the signed embed session
The host application (your Rails/Go/Node backend) calls Emban's /v1/embed/sessions endpoint with the customer it's about to render a dashboard for. The response is a short-lived JWT.
// internal/handler/embed.go
type EmbedClaims struct {
OrgID string `json:"org_id"`
EnvID string `json:"env_id"`
TenantID string `json:"tenant_id"`
DashboardID string `json:"dashboard_id,omitempty"`
WidgetIDs []string `json:"widget_ids,omitempty"`
Permissions *EmbedPermissions `json:"permissions,omitempty"`
jwt.RegisteredClaims
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(h.jwtSecret)The HMAC is over the whole claims set, including TenantID. Any browser that mutates the token by even one character invalidates the signature, which makes the iframe load 401. The browser cannot forge a different tenant — it would need the secret, which never leaves the server.
Defaults that matter:
tenant_idis required at session creation. The handler 400s if the host app forgets it. There is no implicit default.- The token expires in one hour by default and is capped at 24 hours. A leaked token has bounded blast radius.
- Creating a session requires admin scope on the host's API key. End users of your SaaS can't create their own embed tokens for other tenants, even if they find the endpoint.
Step 2 — locked filters and allowed dimensions
Tenant id alone covers the cross-customer case. The harder case is intra-tenant: customer Acme has a sales rep who should see only their own region. The dashboard is the same; the data has to be smaller.
Two knobs handle this, both inside EmbedPermissions on the same JWT:
type EmbedPermissions struct {
LockedFilters map[string]string `json:"locked_filters,omitempty"`
HiddenWidgets []string `json:"hidden_widgets,omitempty"`
AllowDrillDown bool `json:"allow_drill_down"`
AllowedDimensions []string `json:"allowed_dimensions,omitempty"`
AllowedPeriods []string `json:"allowed_periods,omitempty"`
MaxDateRangeDays int `json:"max_date_range_days,omitempty"`
}LockedFilters says "this dashboard renders as if region=eu were always selected and the user can't change it." The viewer's UI hides the filter chip; the server merges the locked value into every widget query before it runs. That happens here:
// internal/handler/embed_data.go
qr := models.QueryRequest{
TenantID: claims.TenantID, // ← from the signed claims
EventName: wc.Query.EventName,
Metric: wc.Query.Metric,
GroupBy: wc.Query.GroupBy,
DimensionFilters: widgetDimFilters, // ← merged with locked filters
TypedFilters: widgetTypedFilters,
Period: period,
}The request body the iframe sends has its own filters too — that's how interactive drill-down works. Those filters get passed through enforceAllowedDimensions, which drops anything the JWT didn't whitelist:
// internal/handler/embed_enforce.go
func enforceAllowedDimensions(filters map[string]string, allowed []string) map[string]string {
if len(allowed) == 0 {
return filters
}
allowSet := map[string]bool{}
for _, d := range allowed {
allowSet[d] = true
}
result := map[string]string{}
for k, v := range filters {
if allowSet[k] {
result[k] = v
}
}
return result
}Step 3 — the database refuses to widen scope
Belt and suspenders. Even if every layer above had a bug, the ClickHouse query is parameterized so that tenant_id appears as a typed query parameter, not as string concatenation:
// internal/query/engine.go
const sql = `
SELECT ${selectParts}
FROM events
WHERE org_id = {org_id:String}
AND env_id = {env_id:String}
AND tenant_id = {tenant_id:String}
AND event_name = {event_name:String}
AND timestamp >= {date_from:DateTime64(3)}
AND timestamp < {date_to:DateTime64(3)}
${groupClause}
${orderClause}
`
rows, err := e.ch.Query(ctx, sql,
clickhouse.Named("org_id", orgID),
clickhouse.Named("env_id", envID),
clickhouse.Named("tenant_id", req.TenantID),
clickhouse.Named("event_name", req.EventName),
// ...
)Three properties fall out of this:
- The tenant id can never be SQL-injected — it's a typed parameter, not a string substitution.
- In the embed data path,
WHERE tenant_id =is mandatory for every read — there is no embed code path that reads "the events table" without it. (Admin tools like the query builder run under a different auth scope and can query across tenants within the caller's own org; that's a deliberate separate trust boundary, not the embed one.) We caught this in embed review by making the absence oftenant_ida compile-time field onQueryRequestrather than an optional map key. - A bad permission whitelist can't widen the query — the only thing missing dimensions can do is pull in more rows that all still belong to the same tenant. That is recoverable; cross-tenant leakage is not.
What this actually buys you
Say you have 200 customers and each customer has on average 30 of their own users looking at dashboards. That is 6,000 individual viewers at any moment, on browsers you don't control, all pointing at the same backend. The properties you want to be true:
- A viewer for customer A cannot see anything for customer B, ever, even by editing the iframe URL, the JWT, or the request body.
- A viewer for customer A who is supposed to see only EU region cannot see other regions for customer A, even by editing the filter chips.
- A leaked JWT has a one-hour blast radius and is bound to the tenant it was minted for.
- A bug in the host app's permission UI cannot widen access — the worst it can do is render the wrong dashboard or show empty state.
The first two are enforced by the layered model: signed token, whitelist filtering, parameterized SQL. The third is the JWT expiry. The fourth is the deliberate "filters are a proposal, not an order" pattern in enforceAllowedDimensions.
Things we explicitly didn't do
- No row-level security at the database. Postgres RLS is great for back-office tools where the connecting user is the tenant. For embedded analytics the connecting user is your backend, identical for every tenant — RLS would have to read the tenant id from a session var that the application sets, which has the same trust problem one layer down. Easier to keep the policy in application code where you can test it.
- No iframe origin allowlist as the primary defense. We support it as hardening — it makes a leaked token meaningfully harder to replay from an arbitrary page, especially against casual abuse — but the trust boundary is the signed JWT plus the server-side
tenant_idpredicate. Origin checks are a useful extra layer, not the layer doing the load-bearing isolation work. - No per-end-user JWTs. Each customer gets one token per session, not one per viewer. The host app already knows who the viewer is; encoding that into Emban would duplicate the host's auth model and make rotation harder.
How to verify in five minutes
Spin up a dashboard, mint a token for tenant acme, load the iframe. Then:
- Open DevTools → Application → JWT inspector. Confirm the
tenant_idclaim is what you expect. - Decode the JWT body, change
tenant_idto a different tenant, try to use it. The signature check rejects it. - Watch the network tab. The data fetch goes to
/v1/embed/datawith the token in the query string. The server reads the tenant from the token, never from anything the request body says. - In the ClickHouse logs, the query runs with
tenant_id = 'acme'as a typed parameter. There is no version of the query without the predicate.
The whole point is that the only way to see another tenant's data is to forge a JWT, which requires the server's secret, which is not in any browser. Everything else is layers of defense around that one fact.