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.
Node.js
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' }
  );
}
Python — no dependencies
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
<?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), '+/', '-_'), '=');
}
Ruby
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.

In your product
<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:

In your product
<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

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