Docs › Integration guide
Single sign-on for your board
If your users are already signed in to your product, they shouldn't have to sign up again to vote. Pass their identity to your board in a signed token and they arrive authenticated, with their name and avatar already right.
1. Turn it on
In Settings → Voter access, switch the mode to Identify / SSO. You'll get an SSO secret and two fields: your product's name (shown to voters) and its login URL (where signed-out visitors are sent).
Keep the secret on your server. Anyone holding it can mint an identity for any of your users. Regenerating it invalidates every token signed with the old one, immediately.
2. Sign a token
Your backend signs a short-lived HS256 JWT. The claims:
| Claim | Notes |
|---|---|
id |
Required. Your stable user id — the key we match on. Never reuse one for a different person. |
email |
Required on first sight of a user. Re-synced on every login. |
email_verified |
Optional, and only matters when the address is already on the
board. true means you confirmed the mailbox, which lets the person inherit
the contact — and the votes and comments — already using it. Without it they still get a
working identity of their own; they just do not take over someone else's. |
name |
Display name. Shown publicly on the board. |
avatarURL |
Optional, https only. |
language |
Optional. The language your product shows this person, as an
ISO 639-1 code: pl, or a locale like pt-BR, which we read as
pt. Stored on the person in Users & Companies. When the board is
available in that language (English, Portuguese, Spanish, Chinese, German, French,
Polish, Arabic, Korean, Italian, Romanian) they see it in that language from this sign-in on, ahead of the board's own
setting and their browser's; they can still pick another from the board's language
menu. |
country |
Optional. ISO 3166-1 alpha-2 code, PL. Stored on the
person, shown in Users & Companies and included in exports. Nothing on the board
changes. |
exp |
Required. Keep it short — ten minutes. The token is exchanged for a session the moment it arrives. |
const jwt = require('jsonwebtoken'); // npm i jsonwebtoken
function kjerrToken(user) {
return jwt.sign(
{
id: String(user.id), // stable, never reused
email: user.email,
email_verified: user.emailConfirmed, // did you check this mailbox?
name: user.fullName,
avatarURL: user.avatarUrl, // optional
language: user.locale, // optional, "pl" or "pt-BR"
country: user.countryCode, // optional, "PL"
},
process.env.KJERR_SSO_SECRET,
{ algorithm: 'HS256', expiresIn: '10m' }
);
}
import hmac, hashlib, json, time, base64
def kjerr_token(user, secret):
def seg(obj):
raw = json.dumps(obj, separators=(',', ':'), sort_keys=True).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip('=')
now = int(time.time())
header = seg({'alg': 'HS256', 'typ': 'JWT'})
payload = seg({
'id': str(user.id), # stable, never reused
'email': user.email,
'email_verified': user.email_confirmed, # did you check this mailbox?
'name': user.full_name,
'avatarURL': user.avatar_url, # optional
'language': user.locale, # optional, "pl" or "pt-BR"
'country': user.country_code, # optional, "PL"
'iat': now,
'exp': now + 600, # ten minutes is plenty
})
signature = hmac.new(secret.encode(), f'{header}.{payload}'.encode(),
hashlib.sha256).digest()
return f"{header}.{payload}." + base64.urlsafe_b64encode(signature).decode().rstrip('=')
<?php
function kjerr_token(array $user, string $secret): string {
$seg = fn(array $o) => rtrim(strtr(base64_encode(json_encode($o)), '+/', '-_'), '=');
$now = time();
$header = $seg(['alg' => 'HS256', 'typ' => 'JWT']);
$payload = $seg([
'id' => (string) $user['id'], // stable, never reused
'email' => $user['email'],
'email_verified' => (bool) $user['email_confirmed'], // did you check it?
'name' => $user['name'],
'avatarURL' => $user['avatar_url'], // optional
'language' => $user['locale'], // optional, "pl" or "pt-BR"
'country' => $user['country_code'], // optional, "PL"
'iat' => $now,
'exp' => $now + 600,
]);
$sig = hash_hmac('sha256', "$header.$payload", $secret, true);
return "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
}
require 'jwt' # gem install jwt
def kjerr_token(user)
JWT.encode({
id: user.id.to_s, # stable, never reused
email: user.email,
email_verified: user.email_confirmed?, # did you check this mailbox?
name: user.name,
avatarURL: user.avatar_url, # optional
language: user.locale, # optional, "pl" or "pt-BR"
country: user.country_code, # optional, "PL"
iat: Time.now.to_i,
exp: Time.now.to_i + 600
}, ENV['KJERR_SSO_SECRET'], 'HS256')
end
3a. Option one — the JS snippet
Drop this on any page of your product where you link to the board. Clicking one of those links hands the token over in the body of a POST, so it never appears in an address bar, a server log or a Referer header.
<script src="https://kjerr.io/static/sdk.js" async></script>
<script>
window.Kjerr = window.Kjerr || function () { (Kjerr.q = Kjerr.q || []).push(arguments) };
Kjerr('identify', {
appID: 'acme', // your board's subdomain
ssoToken: '<token signed by your backend>' // never the secret itself
});
</script>
<!-- clicking this link now signs the user straight in -->
<a href="https://acme.kjerr.io/">Feature requests</a>
Also available: Kjerr('go', {path: '/'}) signs in and
navigates this tab, Kjerr('open') does it in a new one, Kjerr('url')
returns the plain board address (no identity attached), and Kjerr('reset') clears
the token when your user logs out.
With JavaScript off the links still work — the visitor just arrives signed out, and your login URL takes it from there. The same goes for a middle-click or ⌘/Ctrl-click: a new tab opened that way is not something the hand-over can reach, so it opens the board signed out.
3b. Option two — post the token yourself
No SDK. Render a form that posts the token to the portal's SSO endpoint and submit it — the same thing the snippet does, under your own control:
<form method="POST" action="https://acme.kjerr.io/api/sso">
<input type="hidden" name="ssoToken" value="<JWT>">
<input type="hidden" name="redirect" value="/i/42-dark-mode">
<button type="submit">Feature requests</button>
</form>
We verify the signature and expiry, create or refresh the profile, set a session
cookie and send the user on to redirect (which must be a path on the board, not an
absolute URL). If they were part-way through voting, that vote is applied on arrival.
Posting JSON instead, with no redirect, gets JSON back
({"ok": true, …}) and the same session cookie.
Linking to /api/sso?ssoToken=… still works and will
keep working, but we no longer recommend it: a URL is copied into access logs, browser history
and the next page's Referer header, and a token is a credential. If you are on that
flow today, moving to the form above is the whole migration — the token and the claims are
unchanged.
4. What happens on our side
- Signature,
expandnbfare checked. Anything tampered with, expired, or signed with a retired secret is rejected — as isalg: none. - Each token is spent the first time it is used. A second sign-in needs a second token, so a
copy that leaked into a log or a history entry is worth nothing by the time anyone finds it.
The snippet knows this and hands a token over once per page: later clicks on your portal
links are ordinary navigation, which is all they need to be, because the visitor is signed
in to the board by then. Two tokens you mint for the same user in the same second are
byte-for-byte the same token, so only the first will sign in — if you mint them that fast on
purpose, vary a claim, and
iatwith sub-second precision does it. - The profile is yours, so the address you send is the one we use. What we will not do on
an unconfirmed claim is hand over someone else's history: if that address already belongs to
a board contact who confirmed it themselves, the arriving user gets their own identity
instead. Send
email_verified: trueand they inherit it, votes and all. - The profile is matched on
idand re-synced from the token every time, so a name, avatar, language or country change in your product shows up here on the user's next visit. A claim you leave out is left alone, not cleared. - Votes and comments are tied to that identity, so one product user is one vote, and your admins see who asked for what.
- The portal shows no account settings in SSO mode — just a note that the profile comes from your product.
5. Identify: company and plan
Add up to five optional claims to the same token and every voter arrives in Users & Companies with their company attached. Nothing else changes.
{
id: "42", email: "ann@acme.com", name: "Ann Baker",
companyId: "c_819", // your id for the company — the stable key
companyName: "Acme",
companyDomain: "acme.com", // optional
plan: "growth", // optional, shown on the person and the company
mrr: 499 // optional, monthly amount in whole currency units
}
Companies match by companyId, then domain, then exact name, and are created
otherwise. A company named without a domain takes the person's own work-email domain, unless another
company already has it. Plan and MRR refresh on every sign-in and show on the person as well as the
company, so they count even for people with no company.
Server to server
To sync people without waiting for them to visit, POST the same claims as JSON to your board and sign the raw body with your SSO secret:
POST https://acme.kjerr.io/api/v1/identify
Content-Type: application/json
X-Kjerr-Signature: sha256=<hex HMAC-SHA256(secret, body)>
{ "users": [ { "id": "42", "email": "ann@acme.com", "name": "Ann Baker",
"language": "pl", "country": "PL",
"company": { "id": "c_819", "name": "Acme", "domain": "acme.com", "plan": "growth", "mrr": 499 } } ] }
→ 200 { "upserted": 1, "companies": 1, "errors": [] }
Idempotent: send the same person twice and nothing duplicates. Up to 500 people per call, 120 calls a minute.
Signed-out visitors
Someone who isn't signed in can still read and search the board. When they try to
vote we send them to your login URL with ?redirect= pointing back at the page they
were on, and replay the vote once they return.
Questions: hello@kjerr.io