The two keys
Every site in Zenith has two keys, because writing events and reading analytics have different threat models. Getting this right is the whole security model, so it's worth one page.
| Key | Visibility | Authorizes |
|---|---|---|
siteKey | Public — ships in the tracking snippet | Writing events only |
apiKey | Secret — server-side only | Reading that site's analytics |
Why two
The tracking snippet runs in the browser, so siteKey is public — anyone can read it from your page source. That's fine: it can only write events. The worst a leaked site key buys someone is junk traffic on one site.
Reading analytics is different. If siteKey could also read, a competitor could pull your client's traffic straight from a string in their page source. So reading requires the secret apiKey, which is read server-side from the environment and never reaches a browser.
Treat siteKey as readable by anyone — it's in your page source. Keep apiKey and jwtSecret out of client code and out of git: they belong in your deployment environment, never in a committed file.
Where each one goes
config/zenith.ts exports the two halves separately, so the split is structural rather than a comment you have to trust:
import type { ZenithConfig } from "zenith-analytics";
// PUBLIC — ships in the tracking snippet on every page. Safe to hardcode.
export const ZENITH_PUBLIC = {
backendUrl: process.env.ZENITH_URL || "https://zenith.example.com",
siteKey: process.env.ZENITH_SITE_KEY || "zk_your_public_site_key",
};
// PUBLIC + SECRET — server-side only. The secrets have no fallback value:
// absent from the environment means absent, full stop.
export const ZENITH_CONFIG: Partial<ZenithConfig> = {
...ZENITH_PUBLIC,
apiKey: process.env.ZENITH_API_KEY, // reads analytics
passwordHash: process.env.ZENITH_PW_HASH, // gates the dashboard
jwtSecret: process.env.ZENITH_JWT_SECRET, // signs the session cookie
dashboardPath: "/zenith",
protected: true,
siteDomain: "example.com",
};That shape is the point. Note what it buys you:
- The file itself holds no secrets, so it is safe to commit. Your integration lives in code review like the rest of your app; only the values live in the deployment environment.
ZENITH_PUBLICis what<Analytics />receives. There is no path by which the tracker can be handed a secret, because the object you pass it doesn't contain one. Compare a single config object, where one careless"use client"serializes every field — api key included — into the browser payload.ZENITH_CONFIGis what the dashboard route receives, server-side, and never appears in any HTML the browser gets.- The public values have fallbacks, the secrets don't. So a missing
ZENITH_API_KEYreads asundefinedrather than an empty string that looks like a key — which is what the dashboard route's readiness guard tests for.
The three secrets go in your deployment environment:
ZENITH_API_KEY=zk_the_secret_api_key # Zenith console → your site → Setup
ZENITH_PW_HASH=$2b$10$... # from: npx zenith hash
ZENITH_JWT_SECRET=... # 32+ chars: openssl rand -base64 32How the proxy uses the api key
When the dashboard page fetches data, the request goes to your own origin first. The proxy re-authenticates it, then forwards it to Zenith with the api key attached server-side:
Browser ──► yoursite.com/zenith/api/summary (first-party)
│ proxy attaches X-Zenith-API-Key
▼
Zenith service
The api key names the site, so a tampered ?site= query parameter changes nothing — the key decides which site Zenith answers for. See Domain-native dashboard.