Identity verification
Your public write key identifies your project, but it authenticates nobody: anyone can open a browser console and send events as distinct_id: "[email protected]". Identity verification closes that hole — your backend signs a short-lived JWT proving this browser really is this user, and Kilden verifies it on every identified event.
Anonymous traffic is unaffected: there is no identity to forge, so anonymous events never need a token.
How it works
Section titled “How it works”- In project settings you create an identity secret (separate from your write keys). Each secret has a
kid(key id), and several can be active at once, so rotation has no cutover window. - On login, your backend signs a JWT with that secret — HS256, with the user id in
sub. - The SDK sends the token with every event batch as
Authorization: Bearer <token>. - The pipeline verifies it: signature against an active
kid,expvalid, andsubequal to the event’sdistinct_id. The result is stored on every event asverified.
The token
Section titled “The token”Claims payload:
{ "sub": "user_4821", "iat": 1752192000, "exp": 1752195600, "traits": { "plan": "pro" }}sub— required. Must equal thedistinct_idthe browser will use (what you pass toidentify()).exp— required. Keep it short (minutes to hours); the SDK refreshes automatically.kid— required, in the JWT header (not the payload). Identifies which project identity secret signed the token.traits— optional. Signed traits, applied server-verified.- Algorithm: HS256 only. Tokens signed with anything else (including
none) fail verification.
Signing in your backend
Section titled “Signing in your backend”import jwt from 'jsonwebtoken';
// From your project settings → Identity verification.// The secret never leaves your backend.const KILDEN_IDENTITY_SECRET = process.env.KILDEN_IDENTITY_SECRET;const KILDEN_IDENTITY_KID = process.env.KILDEN_IDENTITY_KID;
app.get('/api/kilden-token', requireAuth, (req, res) => { const token = jwt.sign( { sub: req.user.id, // must match the id you pass to kilden.identify() traits: { plan: req.user.plan }, }, KILDEN_IDENTITY_SECRET, { algorithm: 'HS256', expiresIn: '1h', keyid: KILDEN_IDENTITY_KID }, ); res.json({ token });});<?php
use Firebase\JWT\JWT;
// From your project settings → Identity verification.$secret = getenv('KILDEN_IDENTITY_SECRET');$kid = getenv('KILDEN_IDENTITY_KID');
function kildenIdentityToken(string $userId, array $traits = []): string{ $now = time();
$payload = [ 'sub' => $userId, // must match the id passed to kilden.identify() 'iat' => $now, 'exp' => $now + 3600, 'traits' => (object) $traits, ];
return JWT::encode($payload, $GLOBALS['secret'], 'HS256', $GLOBALS['kid']);}(Uses firebase/php-jwt; the fourth argument sets the kid header.)
Wiring the SDK
Section titled “Wiring the SDK”Let the SDK manage the token lifecycle — it refreshes about a minute before exp and retries once on a 401:
kilden.init('YOUR_WRITE_KEY', { getIdentityToken: async () => { const res = await fetch('/api/kilden-token'); if (!res.ok) return null; return (await res.json()).token; },});
// after login:kilden.identify('user_4821');Alternatives: pass a token you already have at init (identityToken: '...'), per call (identify(id, traits, { token })), or imperatively (setIdentityToken(token)). On logout, reset() clears it.
The token travels per batch, in the Authorization: Bearer header. One documented exception: the end-of-page flush can fall back to sendBeacon, which can’t carry headers, so the token goes in the body as identity_token — same TLS protection, checked second.
Enforcement modes
Section titled “Enforcement modes”Verification is always computed — every identified event gets verified: true/false regardless of mode. The mode only governs what happens to unverified identity mutations ($identify, $set, $set_once):
| Mode | Unverified identity mutations from identified users |
|---|---|
off |
Applied normally |
monitor |
Applied normally, but each one is counted so you can see exactly what enforce would block |
enforce |
Not applied: they can’t create persons, link identities or write traits. Identity resolution for unverified identified events becomes lookup-only — existing mappings still attribute, but nothing new is created |
Because monitor measures precisely what enforce would block, the safe rollout is: instrument the token → watch the unverified counter drop to zero → flip to enforce. Flipping changes no data, only behavior.
Two rules hold in every mode:
- Events are never dropped. Failed verification keeps the event with
verified: false; it’s excluded from trust-sensitive consumers (campaign triggers, and messenger reads once messaging over identified channels ships) but stays in your analytics. - Anonymous events always pass. Their
verified: falseis a convention (there’s nothing to verify), not distrust. Server-side events sent with a secret key areverified: true— the secret key is the authentication.
Signed traits
Section titled “Signed traits”Traits inside the JWT are applied as a server-verified $set and take priority over unsigned traits in the same event. Use them for values the browser shouldn’t be able to claim about itself (plan, role, account status). For facts that drive money or messaging, prefer sending the event itself server-side.
Rotation
Section titled “Rotation”Create a new secret (new kid) in project settings, deploy your backend signing with it, then disable the old kid. Multiple active secrets mean zero-downtime rotation; tokens signed by a disabled kid immediately fail verification.