Get the API spec
← All posts Security Published

How to secure a public tracker snippet

By · · 9 min read

A single public-facing node protected by concentric layered defense rings

Every analytics snippet ships its site ID in public page source, so treating a public tracker as if it held a credential is theater. The real question is what an attacker can do with it, and the answer should be: nothing that matters. This post lays out the layered model for securing a public tracker: public identifiers with origin allowlists in front, signed server APIs behind.

01Public by design

Every major analytics vendor has already made this call, and they made it the same way. Segment's browser write key is exposed by design, and the docs say plainly that if you want to hide it you need to move to a server-side library or the HTTP API instead. Mixpanel goes further and says its project token is "not a secret value" and "not a form of authorization": a plain statement that the token was never meant to gate anything. Google Analytics draws the same line differently: the measurement ID that ships in your tag is public, but the Measurement Protocol's api_secret is private, separately managed, and something Google explicitly recommends rotating. Snowplow doesn't even issue a vendor token; the public artifact is your collector URL plus a namespace and app ID, which pushes the entire security boundary onto infrastructure you control.

TraqLyte's tracker site ID sits in the same bucket. It's a routing identifier, not an authorization artifact, and conflating the two is the root modeling error behind nearly every public case of tracker abuse. A routing identifier answers "where does this event go." An authorization artifact answers "who is allowed to send it." Treating the first as if it were the second is theater: the token is in your page source, which means anyone who views source has it, and design that assumes otherwise is designing against a threat that already happened by the time the page loaded.

The abuse pattern that follows from this is well documented outside our own stack. A public write-up in April 2025 showed a Segment write key embedded in a production web console and demonstrated arbitrary event injection straight into Segment's ingest API, which returned success the whole time. A separate write-up found a hardcoded Segment write key in Atlassian's admin console JS and was triaged as a duplicate, not because the finding was wrong, but because the underlying model (browser write keys are public) meant the report wasn't new information to the vendor. Both write-ups call out the same downstream risk: data poisoning and, where ingestion is metered, artificial billing pressure. Google Analytics has lived with the same category for over a decade under the name referral and ghost spam: fabricated hits sent directly at the collection endpoint to pollute reports, no browser involved at all.

02Origin allowlists as the browser-side gate

An Origin allowlist is a real control, but it's worth being precise about what it actually checks. CORS is a browser-enforced mechanism: it governs whether a browser will let cross-origin JavaScript read a response, under the same-origin policy. It says nothing at all about requests that don't come from a browser reading a fetch response: a script, a server, or a copy-pasted curl command sails through untouched, because there's no browser there to enforce anything. Referrer checks are weaker still. Per the current default strict-origin-when-cross-origin policy, a cross-origin request sends only the origin, and stricter policies can suppress the header entirely, so a design that treats Referer as a trust signal will generate both false confidence and false rejections, often at the same time.

That doesn't make Origin checks pointless; it makes them the wrong tool for authorization and the right tool for hygiene. An allowlist stops the casual case: a competitor or a curious developer poking at your endpoint from an unexpected page. It does nothing against a server-to-server replay, because nothing about an HTTP request from a script forces it to carry an honest Origin header. That's exactly why an allowlist has to be backed by something that doesn't rely on the caller's cooperation: a rate limit that caps how much damage a spoofed origin can do even when it gets past the check, and, for anything that writes privileged data rather than a page-view beacon, a signed request that an attacker can't forge without the secret.

The providers that get real origin binding are the ones who control the endpoint end to end. Snowplow can enforce exact redirect-domain matching, TLS requirements, and WAF logic at the collector because the collector is theirs. Segment and Mixpanel can't offer that for a vendor-hosted ingest path; the strongest thing they offer instead is server-side OAuth or a service account, which is a different, stronger primitive than an origin check: it's tied to a secret the browser never sees.

03The server API is a different animal

Everything above applies to the tracker snippet: low-stakes, browser-visible, replay-tolerant by design. TraqLyte's runtime API (/assign, /outcome, /identity/link) is not that. It's the write path that decides which cohort a user lands in and which outcomes count, so it gets a real credential: an API key with an HMAC-signed request, the same shape AWS SigV4 and Slack's webhook scheme use. The signature covers a canonical string built as v1 | METHOD | path+query | timestamp | sha256(body), joined by newlines and computed with hmac.new(secret, ..., hashlib.sha256). Signing the query string, not just the path, matters: a scheme that leaves query parameters free to change under a valid signature is a scheme where the part of the request that decides behavior is exactly the part left unsigned.

Signing alone only proves who produced a message once; it says nothing about when. That's what the timestamp and skew window are for, and it's also where our own signing implementation had a real incident. The skew check computes abs(now - parsed_timestamp) > max_skew: a plain comparison. NaN compares false against every operator in Python, including >, so a client sending X-TL-Timestamp: nan made that comparison false unconditionally, and the skew check passed no matter how old or how far in the future the timestamp was. A signature produced under that timestamp would have been replayable forever by anyone who captured it once: not a theoretical gap, a live one, closed by rejecting non-finite timestamps explicitly with math.isfinite() before any comparison runs, rather than trying to catch it inside the comparison itself. The fix generalizes: a bound expressed as x > limit can never reject NaN, because NaN was designed to fail every ordering comparison, so the finiteness check has to be a separate, earlier gate. Today the window is fixed at 300 seconds in both directions: a stale timestamp and a future-dated one are rejected the same way, because a future timestamp is just as much a replay handle as an old one.

The comparison of the signature itself uses hmac.compare_digest, never ==, for the same reason Stripe and GitHub's webhook docs give: a naive string comparison exits early on the first mismatched byte, and the time that takes leaks how many leading bytes were correct, one probe at a time. And every rejection path (no signature where one is required, missing timestamp, missing secret, bad skew, wrong digest) returns the exact same 401 with the same body. That uniformity is deliberate: a distinguishable error message is an oracle, and an attacker who can tell "this key doesn't require signing" apart from "your signature was wrong" has learned something they shouldn't have. Stripe's own default skew tolerance is five minutes, in the same neighborhood as ours, which is a useful sanity check that 300 seconds isn't an unusually tight or loose choice: it's roughly what the rest of the industry converged on independently.

04Rate limiting that survives deployment reality

A single per-IP limit is the easiest control to ship and the easiest to route around: NAT, mobile carrier IP churn, and rotating cloud egress all defeat it without much effort. TraqLyte checks two independent dimensions on every runtime request and enforces whichever is stricter: a per-API-key limit, scoped separately per route class so a burst of /assign calls can't starve the /outcome quota that measures them, and a lower per-IP ceiling as defense in depth, since /assign and /outcome are invoked by untrusted client-side code and the key itself is effectively public in that context. The per-IP check runs before authentication is even attempted, deliberately: a flood of requests carrying invalid keys is exactly the traffic a per-IP limit exists to absorb, and a limit gated behind a valid credential does nothing to protect the authentication path itself.

Counters live in a database table, not a process-local dictionary, incremented with a single atomic UPDATE ... count = count + 1 rather than a read-modify-write. That distinction is not an optimization detail. An in-process counter is per worker, and with N uvicorn workers behind one process manager the real ceiling silently becomes N times the configured limit; the second worker doesn't know the first worker has already spent the budget. A shared table is the one piece of state every worker already has.

The trap that catches this in practice isn't the algorithm, it's what counts as "the client's IP" once a reverse proxy sits in front. Every request nginx forwards arrives at the app with the same socket peer (nginx's loopback address), so if the app trusts that as the client identity, the per-IP ceiling stops being per-IP and becomes one shared bucket for the entire site, a system-wide kill switch the first traffic spike trips for everyone at once. The fix is X-Forwarded-For, but trusting that header by default just moves the vulnerability one step over: it's client-supplied and free to lie unless something validates who's allowed to set it. TraqLyte's default is the safe one (take the socket peer, ignore the header), and TRAQLYTE_TRUSTED_PROXY_IPS is what unlocks the header, and only conditionally: X-Forwarded-For is honored only when the socket peer is itself on the trusted list, and the address taken is the rightmost entry that isn't also a trusted proxy: right-to-left, because everything to the left of the first untrusted hop could have been prepended by the client itself. Skipping that env var isn't a smaller version of the control; it's the control silently becoming a single global bucket the moment nginx enters the picture, which is exactly the failure mode the deploy checklist calls out by name.

Sources

  • Segment: write key exposure and server-side OAuth docs (browser keys public by design; `tracking_api:write` scope; JWT-based server tokens)
  • Mixpanel: project token documentation ("not a secret value... not a form of authorization") and service-account model for privileged APIs
  • Google Analytics: Measurement Protocol `api_secret` management and rotation guidance; known-bot exclusion and hostname filtering
  • Snowplow: collector-endpoint security model, redirect domain pinning, operator-managed provenance controls
  • RFC 2104: HMAC: Keyed-Hashing for Message Authentication
  • AWS Signature Version 4 (SigV4): canonical request construction and time-bounded signature validation
  • Stripe and Slack webhook signature schemes: timestamp-plus-HMAC replay protection, five-minute tolerance windows
  • GitHub webhook signature verification: constant-time comparison guidance
  • "Neon Segment write-key exposure" bug-bounty write-up (April 2025): arbitrary event injection via a leaked write key
  • Cloudflare and AWS WAF rate-limiting documentation: per-key, per-IP, and per-endpoint aggregation strategies; NGINX leaky-bucket rate limiting

Run experiments your whole stack can call.

Get the API spec

Free SEO & AI-readiness audit

Check your security headers, on-page SEO and AI-crawler readiness in about 30 seconds. No sign-up required.

Run my free audit