[hypequery](https://hypequery.com/) › [Examples](https://hypequery.com/examples) › Multi-tenant embedded analytics on ClickHouse with signed tokens
Index for agents: https://hypequery.com/llms.txt

# Multi-tenant embedded analytics on ClickHouse with signed tokens (https://hypequery.com/examples/multi-tenant-embedded-analytics)

Embed tenant-scoped ClickHouse analytics in your product: your server mints a short-lived token, hypequery enforces the tenant on every query.

## Run it

```bash
npx @hypequery/cli create multi-tenant-embedded-analytics -y
cd multi-tenant-embedded-analytics
pnpm install
pnpm dev
```

- Packages: @hypequery/serve, @hypequery/datasets, @hypequery/clickhouse (tested from hypequery 2.14.0)
- Stack: node, hono, clickhouse
- Source: https://github.com/hypequery/hypequery/tree/main/examples/multi-tenant-embedded-analytics
- Test: `pnpm test`
- Verify: `TOKEN=$(curl -s -X POST localhost:3000/api/analytics/token -H 'content-type: application/json' -d '{"email":"ada@quartz.example"}' | jq -r .token) && curl -s -X POST localhost:3000/api/analytics/datasets/events/query -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"measures":["events","uniqueUsers"]}'`

| Env var | Required | Description |
| --- | --- | --- |
| HYPEQUERY_AUTH_SECRET | no | HS256 secret shared by the token issuer and the verifier. The demo has an insecure default; set your own. |
| PORT | no | HTTP port (default 3000) |
| CLICKHOUSE_URL | no | ClickHouse HTTP endpoint (default http://localhost:8123) |
| CLICKHOUSE_USERNAME | no | ClickHouse user (default: default) |
| CLICKHOUSE_PASSWORD | no | ClickHouse password (default: hypequery, the seed's password) |
| CLICKHOUSE_DATABASE | no | Database holding the seed tables (default hypequery_examples) |

## Demo

![The embed signed in as Ada at Quartz Metrics: four tiles, events by type, and the 400 returned when the browser tries to filter on tenantId](https://hypequery.com/examples/multi-tenant-embedded-analytics/screenshot.png)

```text
Signed in as Ada · Quartz Metrics (enterprise)      last 30 days of seed data

Events   Active users   Error rate   p95 latency
17,054   174            2.43%        673 ms

Events by type   page_view 7,689 · api_call 4,246 · query_run 2,559 · ...

The same token, asked to filter on tenantId explicitly:
HTTP 400  "Cannot filter on tenant field \"tenantId\" when runtime tenancy enforcement is active."
```

Switch to Grace at Relay Works and every number changes to her tenant's. Nothing in the browser ever names a tenant.

## Install

```bash
npx @hypequery/cli create multi-tenant-embedded-analytics -y
cd multi-tenant-embedded-analytics
docker compose -f seed/docker-compose.yml up -d --wait && (cd seed && pnpm install && pnpm seed)   # or set CLICKHOUSE_* for your own ClickHouse
pnpm install
pnpm dev
```

Open http://localhost:3000. Set `HYPEQUERY_AUTH_SECRET` before deploying; the default is for the demo only.

## What it shows

- **Fail-closed tenancy on the dataset** (`analytics/datasets.ts`): `tenantKey: 'tenant_id'` means hypequery injects the tenant predicate itself, rejects queries without tenant context, and returns 400 on an explicit tenant filter.
- **Your server mints the token** (`src/app.ts`): `createAnalyticsTokenIssuer` signs a 15-minute HS256 token with the tenant in `org_id`, inside a route that already knows the user. The demo's "login" is a dropdown; yours is your session.
- **serve verifies and scopes** (`analytics/api.ts`): `createJwtStrategy` checks signature, issuer and audience; `tenant.extract` reads the tenant from the claims; `required: true` and `requiredScopes: ['read:analytics']` close the gaps.
- **An embed with no framework** (`public/embed.js`): fetch a token, call `/api/analytics/datasets/events/query`, render. Drop it in an iframe or a `<script>`.
- **Isolation proven by the smoke test** (`test/smoke.test.ts`): per-tenant totals equal raw SQL for that tenant, no token is 401, a forged token is 401, and a tenant filter is 400.

## Key file

`analytics/api.ts` is the whole security boundary:

```ts
export const issueAnalyticsToken = createAnalyticsTokenIssuer({ ...AUTH, expiresIn: '15m' });

const { serve } = initServe({
  context: () => ({ db }),
  auth: createJwtStrategy(AUTH), // sub → userId, org_id → tenantId, roles, scopes
  basePath: '/api/analytics',
});

export const api = serve({
  queryBuilder: db,
  tenant: { extract: (auth) => auth.tenantId, required: true },
  datasets: {
    events: { dataset: Events, requiredScopes: ['read:analytics'], maxLimit: 200, cache: 10_000 },
  },
});
```

## Architecture

```text
browser (public/embed.js)                 your server (Hono)                              ClickHouse
────────────────────────                  ─────────────────────────────────────────       ──────────
POST /api/analytics/token  ──session──▶   issueAnalyticsToken({ tenantId })  ◀─ user store
        ◀── { token (15m, org_id=153) }
POST /api/analytics/datasets/events/query
  Authorization: Bearer <token>   ─────▶  createJwtStrategy ─▶ tenant.extract ─▶ dataset(tenantKey)
                                           └─ SELECT ... FROM events WHERE tenant_id = 153 ... ─▶
```

## Adapt it

- **Your identity provider.** Replace `createJwtStrategy({ secret })` with `createJwtStrategy({ jwksUri, issuer, audience })` to accept tokens from Auth0, Clerk or Cognito directly, and map claims with `mapClaims` if your tenant claim is not `org_id`.
- **Roles.** Add `requiredRoles: ['admin']` to a dataset entry, or a second dataset for admin-only measures. Roles ride in the token.
- **Cross-tenant reporting.** Internal jobs can run `analytics.execute(Events, query, { runtime: { tenant: { scope: 'all' } } })` server-side. Never expose that path to a browser.
- **More datasets.** Register `subscriptions` or `users` with the same `tenantKey`; the widget picks them up by name.
- **React.** Swap `public/embed.js` for `@hypequery/react` hooks with a `token` resolver, as in the Next.js dashboard example.
- **Hosting.** It is a plain Node server; `pnpm build && pnpm start` runs it anywhere Node runs.

## Related recipes

- [p95-latency](../../recipes/p95-latency): the p95 tile, explained.

## Related examples

- [nextjs-dashboard](https://hypequery.com/examples/nextjs-dashboard): the same dataset pattern with typed React hooks.

Tested on ClickHouse 25.8 with `@hypequery/serve` 0.x, `@hypequery/datasets` 0.29 and Hono 4.

## Key file: analytics/api.ts

```typescript
import { createAnalyticsTokenIssuer, createJwtStrategy, initServe } from '@hypequery/serve';
import { db } from './client.js';
import { Events } from './datasets.js';

/**
 * Embedded analytics, tenant-scoped end to end.
 *
 * 1. Your app authenticates the user and mints a short-lived analytics token
 *    (`issueAnalyticsToken`) whose `org_id` claim is the tenant.
 * 2. The embed sends that token as a Bearer header.
 * 3. serve verifies it (`createJwtStrategy`), extracts the tenant, and every
 *    dataset query is scoped by `tenantKey` before it reaches ClickHouse.
 */
export const AUTH = {
  secret: process.env.HYPEQUERY_AUTH_SECRET ?? 'demo-only-change-me-7c9a4b1f2e3d',
  issuer: 'https://embedded-analytics.example',
  audience: 'hypequery-analytics',
};

/** Mint on your server, in a route that already knows who the user is. */
export const issueAnalyticsToken = createAnalyticsTokenIssuer({
  ...AUTH,
  expiresIn: '15m',
});

const { serve } = initServe({
  context: () => ({ db }),
  auth: createJwtStrategy(AUTH), // sub → userId, org_id → tenantId, roles, scopes
  basePath: '/api/analytics',
});

export const api = serve({
  queryBuilder: db,
  tenant: {
    extract: (auth) => auth.tenantId,
    required: true, // no tenant, no query
  },
  datasets: {
    events: { dataset: Events, requiredScopes: ['read:analytics'], maxLimit: 200, cache: 10_000 },
  },
});
```

## Related

- [Next.js dashboard on ClickHouse with cross-filtering](https://hypequery.com/examples/nextjs-dashboard): A Next.js 16 dashboard over ClickHouse: one typed dataset, a serve endpoint, React hooks, and click-to-filter across every panel.
- [p95 latency per event type in ClickHouse with a typed dataset](https://hypequery.com/recipes/p95-latency): Define p50, p95 and p99 latency once as dataset measures and get the quantile() SQL, the result on a million events, and the exactness trade-off.
- [All examples](https://hypequery.com/examples)
- [/clickhouse-multi-tenant-analytics](https://hypequery.com/clickhouse-multi-tenant-analytics)
- [/clickhouse-saas-analytics](https://hypequery.com/clickhouse-saas-analytics)
- [/clickhouse-hono](https://hypequery.com/clickhouse-hono)
- [/clickhouse-api](https://hypequery.com/clickhouse-api)
- [/clickhouse-nodejs](https://hypequery.com/clickhouse-nodejs)
