kunji

FOR DEVELOPERS

Add Sign in with kunji

Passwordless sign-in where the user holds the keys. Each app sees its own per-app identity — a stable sub — and nothing else. kunji shares no database and runs no backend in the login path: you create a session, show a code, and verify a signed assertion the wallet posts straight to your own callback.

Fastest path — the drop-in button

Include the widget and drop in a button. It renders the official "Sign in with kunji" button, opens the QR / code modal, and reports success — entirely client-side, talking only to your own endpoints.

<script src="https://kunji.cc/rp.js"></script>

<div data-kunji-signin
     data-app-name="Your App"
     data-audience="yourapp.com"
     data-session-url="https://yourapp.com/kunji/session"
     data-callback-url="https://yourapp.com/kunji/callback"
     data-poll-url="https://yourapp.com/kunji/status"
     data-redirect="/dashboard"></div>

Pin rp.v1.js in production. For SPAs, drop data-redirect and listen for kunji:success (e.detail.sub). On Firebase? Use the Firebase guide → · try the button →

You still implement the three endpoints below (session · callback · status) and verify server-side — the widget is UI + orchestration only.

How it works
  1. Create a session on your server — a random challenge with a short TTL.
  2. Show it as a QR (and/or a 6-digit code) the user scans or types in kunji.
  3. The user approves in the wallet, which signs the challenge with their per-app key.
  4. The wallet POSTs the signed assertion to your callback URL — directly, no kunji server.
  5. You verify the signature, audience, freshness, and single-use.
  6. You start your session, keyed by the user's sub.

The only two cross-boundary channels are the QR (your app → wallet) and the signed-assertion POST (wallet → your callback). There is no shared storage and no kunji service in the middle.

STEP 1

Create a login session (server)

Mint a random sessionId and challenge with a short TTL, and store it in your session store. Hardcode your own domain as the audience server-side.

import { randomBytes } from 'node:crypto';
const hex = (n) => randomBytes(n).toString('hex');

// POST /kunji/session  → create a short-lived login session
app.post('/kunji/session', async (req, res) => {
  const sessionId = hex(16);
  const challenge = hex(32);                  // 64-hex nonce the wallet must sign
  const expiresAt = Date.now() + 2 * 60_000;  // 2-minute TTL

  await db.set(`loginSessions/${sessionId}`, {
    challenge, status: 'pending', sub: null, expiresAt,
    audience:    'yourapp.com',                       // YOUR domain
    callbackUrl: 'https://yourapp.com/kunji/callback' // MUST be same-site as audience
  });

  res.json({ sessionId, challenge, expiresAt });
});
STEP 2

Render the QR / code (frontend)

Encode the v2 discoverable payload into a QR. The same payload also powers a same-device deep link and the typed 6-digit code. Then poll your own session for status === 'approved'.

Using rp.js? You need none of this — the widget builds and renders the QR for you (it emits the compact K1 encoding below, which scans more easily). This snippet is only for hand-rolling your own QR.

import QRCode from 'qrcode';

const { sessionId, challenge, expiresAt } =
  await (await fetch('/kunji/session', { method: 'POST' })).json();

const payload = {
  kunjiAuth: 'v2',
  mode: 'discoverable',
  sessionId, challenge, expiresAt,
  audience:    'yourapp.com',
  callbackUrl: 'https://yourapp.com/kunji/callback',
  appName:     'Your App',
  returnUrl:   window.location.href     // optional: same-device return
};

const qrDataUrl = await QRCode.toDataURL(JSON.stringify(payload));
// render qrDataUrl as an image; poll your session until status === 'approved'
STEP 3

Receive & verify the assertion (server)

The wallet POSTs the signed assertion to your callbackUrl. Verify all seven rules below before trusting it. The canonical-JSON + Ed25519 scheme must match kunji exactly.

import { createHash } from 'node:crypto';
import { ed25519 } from '@noble/curves/ed25519.js';

// keys sorted, no whitespace — MUST match kunji's signer
const canonicalJson = (o) =>
  (o === null || typeof o !== 'object' || Array.isArray(o))
    ? JSON.stringify(o)
    : JSON.stringify(Object.fromEntries(
        Object.keys(o).sort().map((k) => [k, o[k]])));

const b64   = (s) => Buffer.from(s, 'base64');
const subOf = (pk) => createHash('sha256').update(pk, 'utf8').digest('hex');

// POST /kunji/callback  ← wallet posts the signed assertion here
app.post('/kunji/callback', async (req, res) => {
  const { publicKey, signedPayload, signedToken } = req.body || {};
  const session = await db.get(`loginSessions/${signedPayload?.sessionId}`);
  const now = Date.now();

  if (!publicKey || !signedPayload || !signedToken)   return res.status(400).json({ error: 'malformed' });
  if (!session || session.status !== 'pending')       return res.status(400).json({ error: 'bad_session' });
  if (now > session.expiresAt)                        return res.status(400).json({ error: 'expired' });
  if (signedPayload.challenge !== session.challenge)  return res.status(400).json({ error: 'challenge' });
  if (signedPayload.audience  !== 'yourapp.com')      return res.status(400).json({ error: 'audience' });
  if (signedPayload.sub       !== subOf(publicKey))   return res.status(400).json({ error: 'sub' });
  if (Math.abs(now - signedPayload.timestamp) > 120_000) return res.status(400).json({ error: 'stale' });

  const ok = ed25519.verify(
    b64(signedToken),
    new TextEncoder().encode(canonicalJson(signedPayload)),
    b64(publicKey));
  if (!ok) return res.status(400).json({ error: 'signature' });

  // single-use: mark consumed and record the user, keyed by sub
  await db.update(`loginSessions/${signedPayload.sessionId}`,
    { status: 'approved', sub: signedPayload.sub });

  res.json({ status: 'ok' });
});
STEP 4

Start your session

Key your user by sub — a first-seen sub is a new account, a returning one is an existing user (the SSH / passkey model). On Firebase, mint a custom token with uid = sub so your existing security rules keep working unchanged.

// your frontend sees status === 'approved' for its session, then:
const token = await admin.auth().createCustomToken(session.sub, {
  kunjiPub: publicKey,            // optional claim
});
// frontend: signInWithCustomToken(token) → request.auth.uid === sub

Using the drop-in widget? Also expose a read-only GET /kunji/status?sessionId={ status, sub } that the widget polls until approved.

Message formats

QR payload — your app → wallet

{
  "kunjiAuth": "v2",
  "mode": "discoverable",
  "sessionId": "8f3c…",
  "challenge": "64-hex-byte nonce",
  "audience": "yourapp.com",
  "callbackUrl": "https://yourapp.com/kunji/callback",
  "appName": "Your App",
  "expiresAt": 1750000000000,
  "scope": ["profile"]
}

scope is optional. Add ["profile"] (widget: data-scope="profile") to ask the wallet to offer sharing a custom name/photo. The user may decline, so always fall back to the default identity.

Compact K1 encoding. The wallet also accepts a compact QR string K1:<base32…> — the same request packed to an all-uppercase-alphanumeric string so the QR encodes in the denser alphanumeric mode (a much less dense, easier-to-scan code). rp.js emits it for you; hand-rolled RPs may send JSON or K1. The same-device deep link stays JSON. (Spec: discoverable-login §5.1.1.)

Signed assertion — wallet → your callback

{
  "publicKey": "base64 Ed25519 public key (the user's identity for this app)",
  "signedPayload": {
    "sessionId": "8f3c…",
    "challenge": "64-hex-byte nonce (echoed back)",
    "audience": "yourapp.com",
    "sub": "hex SHA-256 of the base64 publicKey",
    "timestamp": 1750000000123,
    "claims": { "name": "Ada Lovelace", "picture": "data:image/webp;base64,…" }
  },
  "signedToken": "base64 Ed25519 signature over canonicalJson(signedPayload)"
}

claims appears only if the user consented (see scope above). It's signed (tamper-evident) but self-asserted and never verified — treat it as untrusted.

Showing the user — name & avatar

kunji authenticates; your app owns the profile. You get a stable, anonymous sub — not a Google-style verified name/email/photo. So you never show a blank avatar, every sub maps to a friendly default name + key-sigil avatar (a key-shaped mark derived from the sub you already have — distinct per app, stable, unlinkable; no PII, no extra call):

// rp.js exposes the helper — no assertion change needed
const { name, avatarSvg, avatarDataUri } = kunji.handle(sub);
profileEl.textContent = name;          // e.g. "Amber Marlowe"
imgEl.src = avatarDataUri;             // kunji key-sigil (inline SVG also available)

If the user shared a custom profile, prefer claims — but render it as untrusted: HTML-escape name, show picture only as a client-side <img> (never server-fetch it), and never use claims for auth. Store your profile keyed by sub; for a multi-app suite, use one consistent audience for one shared identity. Need a contact email? Ask the user directly — kunji doesn't provide one.

Verification checklist
  • Session is known, not expired, and status === 'pending'.
  • challenge equals the session's stored challenge (anti-replay).
  • audience equals your own domain (anti-relay / phishing).
  • Signature verifies over canonicalJson(signedPayload) with the presented publicKey.
  • sub equals SHA-256(publicKey) recomputed server-side.
  • timestamp is within ±2 minutes of server time.
  • Mark the session consumed — reject any re-submission (single-use).
  • If claims is present, treat it as untrusted: HTML-escape the name, render the picture client-side only, never use it for authorization.
Security notes

Audience binding stops a malicious site relaying your QR — the wallet signs your domain and shows it to the user for confirmation. The callback must be HTTPS and same-site as the audience; kunji rejects mismatches. The challenge is single-use and session-bound. Keys never leave the vault — only signatures are emitted. One caveat to surface to users: the kunji vault (and its recovery key) becomes the root of access to every connected app, so it's load-bearing — back up the recovery key.

Beyond anonymous sign-in

The same wallet can hold verified credentials and let a user prove a fact from one — selective disclosure over standard OpenID4VCI / OpenID4VP (SD-JWT VC), unlinkable and with no kunji backend in the path — or authorize an AI agent to act for them via a scoped, expiring, holder-of-key capability.

Reference

Five complete, working relying parties live in the kunji repo — kunji-login-demo (Firebase), kunji-node-demo (plain Node — no Firebase, no framework, with a wallet simulator), kunji-agent-demo (plain Node, also accepts agent logins via POST /kunji/agent), and two for local / self-hosted setups with no tunnel: kunji-relay-demo (test with a real phone) and kunji-selfhosted-demo (your own Firebase + an on-prem worker on a dynamic IP). All implement the full verifyAssertion. Try the hosted one live: