Use casesProduct teamsCustomer analytics01 / 12
How it fits together

One query for every tenant, and each sees only its own

Your users call with the token they already sign in with. Its plan picks a role, with that plan's queries, rate limit and quota, counted per user. Its tenant fills the query's filter, so the request has nothing to change. The cache keeps one entry per tenant, and tenants never share one.

1saved query for every tenant
0tenant ids in the request
2plans, each a role
Your product
The governed core
Your data
Identity provider↗
signing keys · fetched once
Redis↗
cache · quota counts
app.example.com/analytics
aalice@acme · pro↗
Revenue by month
tenant acme Download CSV Last response 200 · X-Cache: HIT
X-Quota-Remaining-Calls: 99,411
The governed coreGET /q/revenue_by_month↗

01 · Token

Verified with your provider's keys: signature, expiry, issuer, audience.

jwt:alice@acme 401

02 · Plan

The plan claim picks the role: its queries, rate limit and quota, per user.

pro · 99,412 left 429 quota_exceeded

03 · Tenant

tenant_id comes from the token. A request can't send it.

tenant_id = acme 400 param_from_claim

04 · Cache

One entry per query, tenant, format and page.

acme · json · HIT v3 · acme · json v3 · globex · json v3 · acme · csv X-Cache: HIT
plan: free → role free require_claim_binding ✓
queries: revenue_by_month quota: 1,000 calls / monthStable rate_limit: 30/minute
plan: pro → role pro require_claim_binding ✓
queries: revenue_by_month, cohort_retention quota: 100,000 calls / monthStable rate_limit: 300/minute
↗

ClickHouse

analytics tenant_revenue
tenant_id = acme12 rows tenant_id = globex12 rows tenant_id = initech12 rows tenant_id = umbrella12 rows
+ 1,204 more tenants WHERE tenant_id = :tenant_id
or DuckDB · Parquet in your bucket↗
What the token carriesverified before any of it is used
"iss": "https://login.example.com/", "aud": "analytics-api", "exp": 1793522400}
Stablecompatibility promise Every run is recorded as jwt:<user>. A cache hit isn't, because nothing ran, but it counts toward the quota.
Your productEach user calls with the token they sign in withThe page calls directly. No key sits in it.
The tokenIt carries the tenant and the planVerified with your identity provider's keys before anything in it is used.
The governed coreToken → plan → tenant → cacheThe plan picks a role: queries, rate limit, quota, per user. The tenant fills the query's filter, and a request that sends one gets 400. One cache entry per tenant; tenants never share one.
AsksIdentity provider, RedisSigning keys fetched once; the cache and quota counts shared across instances.
Your dataClickHouse or DuckDB: only the caller's tenantOne saved query, WHERE tenant_id = :tenant_id, for every tenant.

The same governed core as the full architecture.

For SaaS products

Put analytics in front of your customers, one tenant at a time

Customers want their own numbers inside your product. Building that usually means an analytics backend with tenant isolation you have to get exactly right. Here, each user signs in with the token your app already issues, and the tenant comes from that token, never from the request.

✓from_claim fills tenant_id from the verified token. A user who sends one anyway gets 400; a token without the claim gets 403.
✓QUERYAPIGATE_JWT_ROLE_CLAIM maps a claim such as plan to a role, so each plan gets its own queries, rate limit and quota, counted per user.
✓cache_ttl per query, keyed on each caller's own values, so tenants never share a cached response. ClickHouse or DuckDB underneath for dashboards that load fast.
✓CSV or XLSX for the download button, and every user's runs recorded as jwt:<user>.
Customer analytics
# the saved query: the tenant is never a request parameter
curl -X POST https://data.example.com/api/v1/queries \
  -H 'X-API-Key: <admin>' -H 'Content-Type: application/json' -d '{
  "name": "revenue_by_month", "description": "Monthly revenue",
  "connection_name": "analytics", "cache_ttl": 60,
  "sql": "SELECT month, revenue FROM tenant_revenue WHERE tenant_id = :tenant_id",
  "parameters": {"tenant_id": {"type": "string", "from_claim": "tenant_id"}}
}'

# users sign in with your token; a claim picks their plan's role
QUERYAPIGATE_JWT_JWKS_URL=https://login.example.com/.well-known/jwks.json
QUERYAPIGATE_JWT_ISSUER=https://login.example.com/
QUERYAPIGATE_JWT_AUDIENCE=analytics-api
QUERYAPIGATE_JWT_ROLE_CLAIM=plan

GET /q/revenue_by_month?format=csv
Authorization: Bearer eyJhbGciOi…   # acme's rows, and only acme's
GET /q/revenue_by_month?tenant_id=globex   # 400
More use casesAll twelve →

Not sure which fits?

The example APIs ship with a generated database and one scoped key per scenario, so you can try each of these in a few minutes without connecting anything of your own.

Run the examplesCompare alternatives