Trust, but verify: making distinct_id spoof-proof
on this page ▾
Open your own product in a browser. Open devtools. Type two lines:
kilden.identify('[email protected]')
kilden.track('subscription_upgraded', { plan: 'enterprise' })Congratulations — as far as most analytics pipelines are concerned, you are now the CEO of BigCo, and you just upgraded to enterprise. The event lands in the store looking exactly like the real thing, because the pipeline's entire notion of who rests on a value the browser was allowed to type.
This isn't an exotic attack. It's the default state of client-side analytics, and everyone has quietly agreed not to look at it. For a dashboard it's noise. For experiment results, billing-adjacent counts, or a campaign engine that sends emails when events happen, it's a loaded gun. The usual mitigations — IP heuristics, rate limits — raise the cost of lying. We wanted the pipeline to know, per event, whether the identity claim was ever proven.
The design that follows is deliberately unexciting. That's the point: the interesting part isn't the cryptography — it's deciding what happens to the events that fail it.
Three kinds of traffic, three answers
"Is this event trustworthy?" turns out to have a different answer per source, and only one of the three needs any cryptography at all:
| traffic | verified |
|---|---|
anonymous events (anon_ + UUIDv7) |
false by convention — there is no anchor to verify, and consumers that require verification always let anonymous traffic through |
| server-side events (secret key) | true — the secret key is the authentication |
| identified events from the browser | computed against a JWT, always |
Detecting "identified" costs nothing: Kilden's anonymous ids have a recognizable shape (anon_ + UUIDv7). Any distinct_id that doesn't look like that is a user id someone claimed, so it's subject to verification. Zero lookups to decide.
Signed identity tokens
Your backend — the only party that actually knows who is logged in — signs a short-lived HS256 JWT whose sub claim is the user id. The signing secret is a per-project identity secret, separate from your write key, and rotatable via the kid header: multiple secrets can be active at once, so rotation has no cutover window.
import jwt from 'jsonwebtoken'
app.post('/api/kilden-token', requireAuth, (req, res) => {
const token = jwt.sign(
{ sub: req.user.id }, // must equal the distinct_id it vouches for
process.env.KILDEN_IDENTITY_SECRET,
{ expiresIn: '15m', keyid: process.env.KILDEN_IDENTITY_SECRET_KID },
)
res.json({ token })
})The token can also carry traits. Signed traits apply with priority over unsigned ones from the same event — for facts like plan that the browser should never get to assert on its own.
Wiring the SDK
The client integration is one config key:
kilden.init('wk_...', {
getIdentityToken: () =>
fetch('/api/kilden-token').then(r => r.json()).then(d => d.token),
})The SDK calls it at init and again at identify(), persists the token across page loads, and refreshes shortly before exp — which is why 10–15 minutes is the sweet spot: short enough to be a real credential, long enough that persistence saves the fetch on every navigation.
The token travels once per batch — Authorization: Bearer on POST /capture — not per event; a batch belongs to one session of one browser. One documented exception: the end-of-page flush can fall back to sendBeacon, which can't carry headers, so the token rides in the batch body there. Same TLS, same protection — the header preference is hygiene, not cryptography.
And if the token provider is down entirely? The flush waits briefly, then releases the queue: events go out unverified rather than getting stuck or lost. Verified late > unverified > dropped is the ordering everything in the SDK follows.
What "enforce" actually does
Here's where Kilden deviates from the obvious design — and the obvious design is wrong. The obvious design rejects unverified events with a 401. Do that, and you've handed anyone with your public write key a way to probe your enforcement, and you've thrown away data you can never get back.
Instead, every event of an identified user gets a verified boolean computed at ingest — always, in every mode — and unverified events are kept, flagged, and excluded from the consumers where trust matters: the campaign engine never triggers on them, and the messenger never trusts them. They're data — just not trustworthy data.
Enforcement is a per-project setting with three modes:
| mode | unverified identity mutations | use it when |
|---|---|---|
| off | applied normally | before your token endpoint exists |
| monitor | applied, logged and counted — verified=false on the event |
rolling out; watch the counter drop to zero |
| enforce | not applied to profiles — no new persons, no trait writes, no merges | steady state |
In enforce, unverified identified events also resolve identity in lookup-only mode: they can attach to an existing mapping, but they can never create persons or mappings. Without that rule, anyone with your public write key could quietly inflate your persons table with forged identities even while enforcement blocks their trait writes.
Because verified is computed identically in every mode, monitor measures exactly what enforce would block. Turning enforcement on changes no data — only behavior. That's what makes flipping it a decision instead of a leap.
What it costs
Verification runs in the enricher, not in capture: the ingest hot path stays dumb and fast, and just propagates the token inside the event envelope (with a 4KB anti-abuse cap). The only real cryptographic work is one HMAC per identified batch's token — noise next to the JSON parsing around it. On the client, it's one token request per ~15 minutes per active session, and usually zero on navigation thanks to the persisted token.
"Trust the client" was never a real option — it was just the default. If a distinct_id matters enough to email someone about, it's worth one HMAC to prove it.
1M events/month on the free tier. Set up in about two minutes.