Engineering

Analytics API Key Security: Why a Stolen Key Is Useless

Your browser key ships in view-source on every page. Here's how four scoped key families and an Origin gate make that a non-event instead of a breach.

July 27, 2026 • 8 min read

View source on any site running ClickStream and the API key is sitting right there:

<script src="https://t.example.com/sdk.js?key=cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"></script>

That tag serves the 344-byte loader, which pulls the ~56.5 KB gzipped tracker bundle from your own first-party subdomain. And the key is visible to anyone who presses Cmd+U. Every developer has the same reaction the first time: isn't that a leak?

It isn't — because analytics API key security can't come from secrecy. A credential shipped to every browser on earth is public the moment your page loads, and pretending otherwise is how vendors end up with security theater. The honest design goal is different: make the exposed key worthless anywhere except the site it was minted for. ClickStream does that with two mechanisms — scoped key families and domain gating — and this post walks through exactly how each one behaves, down to the error codes.

Four Key Families, Scoped by Where They Run

ClickStream keys are scoped to a single tenant, and every key carries a keyType that decides whether the browser provenance gate applies. You don't set keyType by hand — it's determined by the property type you create in the dashboard (Website, Mobile app, or Server):

Key prefix Where it belongs keyType Origin gate
cs_live_* Production website pages browser (the default) Enforced — writes and Signals reads need a matching Origin/Referer
cs_test_* Non-production websites browser Enforced — same rules as production
cs_mob_live_* Native mobile apps mobile Exempt — native apps post directly, with no Origin
cs_srv_live_* Backend servers server Exempt — backends post directly, with no Origin

One honest detail about the first two rows: the cs_live_ / cs_test_ prefix is advisory. The collector treats both identically at runtime. The prefix exists so that tools which scrub secrets from logs — pre-commit hooks, CI masking, error-tracker filters — can pattern-match on it and keep production keys out of error tickets. That's a small design choice that says something about the whole system: even the parts that aren't security boundaries are built so your existing security tooling works with them.

Why a Browser Key Is Safe to Expose

The browser key's protection is domain gating. When a website key has configured domains, every browser-facing event write and every Signals snapshot read must arrive with an Origin or Referer header matching one of them. Wildcards are supported — *.example.com matches example.com, www.example.com, and app.example.com.

So walk through what an attacker can actually do with your copied cs_live_* key:

# From a page on a hostname you never registered:
# the browser stamps THEIR Origin, and the collector answers
POST /v1/events  →  403  { "error": "domain_not_allowed", ... }

# From a headless script that sends no Origin/Referer at all:
POST /v1/events  →  403  { "error": "origin_required", ... }

The first case is the important one. Browsers attach the Origin header themselves on cross-origin requests — page JavaScript doesn't get to forge it. A competitor who pastes your key into their own site is announcing their hostname on every request, and events sent from an unregistered hostname are rejected with 403 domain_not_allowed. The same gate covers reads: a page on the wrong domain can't pull visitor snapshots with your key either.

Spoofing a native app doesn't work either

The obvious next move is to skip the browser entirely and replay events from a script pretending to be a mobile app — an OkHttp or CFNetwork user agent with no Origin. That's expected traffic under a mobile or server key, but the exemption is gated on key posture, not user agent: a library UA under a website key still forces a bot classification. A copied website key can't escape detection by dressing up as a native app. And because ClickStream bills on human pageviews, bot-classified junk doesn't inflate your invoice while you clean it up.

What a Stolen Browser Key Can Never Read

Write pollution is the annoying failure mode; the scary one is reads. Could someone with your public key watch your visitors in real time? No — by construction, not by policy. Tenant-wide live surfaces — the dashboard's live sessions and the Signals Feed WebSocketdo not accept public site keys at all. They authenticate with short-lived stream tokens minted through an authenticated dashboard or approved server flow, passed as:

Sec-WebSocket-Protocol: clickstream-v1, <stream_token>

A key copied from website code simply has no path to tenant-wide live visitor activity. Feed access is also plan-gated — only Scale+ accounts can mint Signals Feed stream tokens (see plans) — and the feed itself is read-only telemetry.

What the browser key can read is deliberately narrow: the per-visitor snapshot at /v1/signals/:visitorId, plus the per-visitor browser stream, both of which run through the same domain gate, the active sessionId, and plan gating. That's the read your page code performs with the Signals API:

import { configure, getVisitor, isHighIntent } from '@clickstreamhq/signals';

// configure() must run before any read
configure({
  apiKey: 'cs_live_your_browser_key',   // public by design
  endpoint: 'https://t.example.com',    // your verified tracking domain
});

const visitor = await getVisitor();
if (!visitor.bot.isBot && isHighIntent(visitor)) {
  // visitor.scores.intent >= 70
}

@clickstreamhq/signals is a developer preview (0.1.0-alpha); of the ClickStream packages, only @clickstreamhq/sdk is stable today. The API above is the real, current surface — see Getting Started with the Signals API for the full tour.

Server Keys Flip the Rule

Backends are the mirror image of browsers: they send no Origin header, ever. That's why cs_srv_live_* (and cs_mob_live_*) keys exist. They're minted on a dedicated property with no first-party domain and no DNS, they're exempt from the provenance gate entirely, and they lean on a tighter per-key rate-limit bucket instead.

The scoping cuts both ways, and the failure mode people actually hit is using the browser key server-side: a cs_live_* key reaching the collector from a backend has no Origin, so the domain-gated request is rejected — the exact trap the Next.js adapter docs warn about with getServerVisitor(). Browser key in the page, server key in the server. They coexist on purpose.

And the server key's handling is the opposite of the browser key's: because it skips the origin gate, it must never appear in page source. Keep it where the docs tell you to — your deployment's secret manager (a Vercel environment variable, GitHub Actions secret, 1Password, Doppler), read at runtime, rotated through support when needed.

Graceful Failure: The One-Line Comment Script

There's a third scenario between "valid key" and "stolen key": a key whose account is suspended. Most vendors handle this badly — the tag 404s, the console fills with red, and the site owner's visitors see the fallout of a billing dispute they know nothing about.

ClickStream's collector does something quieter. When the account behind a key is suspended or blocked, /sdk.js still answers 200 — with this entire script as the body:

/* ClickStream tracking is not active for this site. */

No console errors, no failed network requests, no account-state details leaked to site visitors — just a comment. The response is served no-store, so an unsuspended account gets its real tracker back on the very next page load; nothing stale gets pinned in a cache. The actual enforcement lives where it belongs, at ingest: every /v1/* call from a suspended account gets 403 account_suspended. Script delivery was never the security boundary — the data plane is. It's the same fail-open philosophy that governs billing limits: your site's rendering is never hostage to your analytics vendor's account state.

Key Hygiene: Headers, Query Params, and Rotation

Two operational notes worth internalizing. First, the collector accepts the key two ways — the X-API-Key header, or a ?key= query parameter (which exists for the SDK loader tag and sendBeacon, neither of which can set headers). For your own server-to-server calls, prefer the header: query strings can end up in CDN access logs, browser history, and referrer headers, while the X-API-Key header never does.

Second, rotation. It's support-assisted today: request a new key, deploy it to every surface that uses it (SDK config, server env vars, CI secrets), give cached page loads time to drain, then have the old key revoked. Propagation is fast — the collector caches key config for about two minutes per isolate, so a rotation takes effect globally within roughly two minutes of the switch. Every collector response also carries an X-Request-ID for correlating a client error to a specific log line, and an X-RateLimit-Remaining header so you can watch usage without polling the dashboard.

For reference, the full auth-layer error taxonomy you'll meet in the wild:

HTTP error Meaning
401 missing_api_key No X-API-Key header or ?key= param provided
401 invalid_api_key Key not found
403 domain_not_allowed Origin/Referer doesn't match any configured domain
403 origin_required Domain-gated key with no Origin on the event-ingest POST
403 account_suspended The key's account is suspended — enforced at ingest, not at script delivery
403 plan_upgrade_required The key's plan tier lacks the requested feature
429 rate_limit_exceeded Sliding-window limit hit; Retry-After carries the cooldown

The Bottom Line

A key you can't hide has to be a key that isn't worth stealing. That's the whole design, in four moves:

If you're setting this up for the first time, the install guide walks through registering your tracking domain — the step that makes the domain gate yours.

Secrecy is a property you lose once. Scope is a property the collector re-checks on every request.

Ship the Key in the Open

Register your domain, paste the tag, and let the collector do the enforcing. If someone copies your key, they get a 403 — you get on with your day.

Start free