Skip to content

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.

  1. 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.
  2. On login, your backend signs a JWT with that secret — HS256, with the user id in sub.
  3. The SDK sends the token with every event batch as Authorization: Bearer <token>.
  4. The pipeline verifies it: signature against an active kid, exp valid, and sub equal to the event’s distinct_id. The result is stored on every event as verified.

Claims payload:

{
"sub": "user_4821",
"iat": 1752192000,
"exp": 1752195600,
"traits": { "plan": "pro" }
}
  • subrequired. Must equal the distinct_id the browser will use (what you pass to identify()).
  • exprequired. Keep it short (minutes to hours); the SDK refreshes automatically.
  • kidrequired, 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.
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.)

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.

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: false is a convention (there’s nothing to verify), not distrust. Server-side events sent with a secret key are verified: true — the secret key is the authentication.

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.

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.