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.

KeyVisibilityAuthorizes
siteKeyPublic — ships in the tracking snippetWriting events only
apiKeySecret — server-side onlyReading 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_PUBLIC is 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_CONFIG is 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_KEY reads as undefined rather 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 32

How 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.