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.
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.
- Create a session on your server — a random challenge with a short TTL.
- Show it as a QR (and/or a 6-digit code) the user scans or types in kunji.
- The user approves in the wallet, which signs the challenge with their per-app key.
- The wallet POSTs the signed assertion to your callback URL — directly, no kunji server.
- You verify the signature, audience, freshness, and single-use.
-
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.
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 });
});
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'
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' });
});
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.
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.
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.
-
Session is known, not expired, and
status === 'pending'. -
challengeequals the session's stored challenge (anti-replay). -
audienceequals your own domain (anti-relay / phishing). -
Signature verifies over
canonicalJson(signedPayload)with the presentedpublicKey. -
subequalsSHA-256(publicKey)recomputed server-side. -
timestampis within ±2 minutes of server time. - Mark the session consumed — reject any re-submission (single-use).
-
If
claimsis present, treat it as untrusted: HTML-escape the name, render the picture client-side only, never use it for authorization.
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.
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.
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: