FIREBASE
Sign in with kunji on Firebase
The exact stack kunji's own demo runs: Cloud Functions + Firestore + Hosting. Drop in the button widget, add three small functions, and your users sign in with keys they hold — no passwords, no Google. kunji shares no database and runs no backend in this flow; the wallet posts the signed assertion straight to your callback.
1 · Add the button
Include the widget and drop in a button element pointing at your Cloud Function URLs. It renders the official button, opens the QR / code modal, and reports success — all client-side.
<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 a version in production:
https://kunji.cc/rp.v1.js (optionally with an SRI hash). For
SPAs, omit data-redirect and listen for the
kunji:success event — e.detail.sub is the
user's stable id. The widget talks only to your endpoints; it never calls a
kunji server.
See it running →
Wire endpoints same-site (firebase.json)
Hosting rewrites make your functions resolve on your own domain — so
callbackUrl is same-site as audience,
which kunji requires.
{
"hosting": {
"rewrites": [
{ "source": "/kunji/session", "function": { "functionId": "createSession", "region": "us-central1" } },
{ "source": "/kunji/status", "function": { "functionId": "getSessionStatus", "region": "us-central1" } },
{ "source": "/kunji/callback", "function": { "functionId": "kunjiCallback", "region": "us-central1" } },
{ "source": "**", "destination": "/index.html" }
]
},
"functions": { "source": "functions" }
}
createSession — mint a challenge
A random sessionId + challenge with a
short TTL, stored in your Firestore. The ttl field
lets a Firestore TTL policy auto-delete old sessions. Return a 6-digit
code too if you want the code tab.
import { onRequest } from 'firebase-functions/v2/https';
import { initializeApp } from 'firebase-admin/app';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { randomBytes } from 'node:crypto';
initializeApp();
const db = getFirestore();
const hex = (n) => randomBytes(n).toString('hex');
export const createSession = onRequest({ cors: true }, async (req, res) => {
const audience = 'yourapp.com'; // hardcode your domain
const callbackUrl = 'https://yourapp.com/kunji/callback'; // same-site as audience
const sessionId = hex(16);
const challenge = hex(32);
const expiresAt = Date.now() + 2 * 60_000; // 2-minute TTL
await db.collection('loginSessions').doc(sessionId).set({
challenge, audience, callbackUrl, status: 'pending', sub: null, expiresAt,
ttl: Timestamp.fromMillis(expiresAt + 5 * 60_000), // for the TTL policy
});
res.json({ sessionId, challenge, expiresAt });
});
verify.js — the assertion checks
Canonical JSON + Ed25519, identical to kunji's signer. Enforces every rule in the checklist below.
import { createHash } from 'node:crypto';
import { ed25519 } from '@noble/curves/ed25519.js';
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');
export function verifyAssertion({ assertion, session, audience, now = Date.now() }) {
const { publicKey, signedPayload, signedToken } = assertion || {};
if (!publicKey || !signedPayload || !signedToken) return { ok: false, error: 'malformed' };
if (!session || session.status !== 'pending') return { ok: false, error: 'bad_session' };
if (now > session.expiresAt) return { ok: false, error: 'expired' };
if (signedPayload.challenge !== session.challenge) return { ok: false, error: 'challenge' };
if (signedPayload.audience !== audience) return { ok: false, error: 'audience' };
if (signedPayload.sub !== subOf(publicKey)) return { ok: false, error: 'sub' };
if (Math.abs(now - signedPayload.timestamp) > 120_000) return { ok: false, error: 'stale' };
const ok = ed25519.verify(b64(signedToken),
new TextEncoder().encode(canonicalJson(signedPayload)), b64(publicKey));
return ok ? { ok: true, sub: signedPayload.sub } : { ok: false, error: 'signature' };
}
kunjiCallback — receive & verify
The wallet POSTs the signed assertion here. On success, mark the session approved and
(for real auth) mint a custom token so
request.auth.uid === sub and your existing rules keep working.
import { getAuth } from 'firebase-admin/auth';
import { verifyAssertion } from './verify.js';
export const kunjiCallback = onRequest({ cors: true }, async (req, res) => {
const assertion = req.body || {};
const ref = db.collection('loginSessions').doc(assertion?.signedPayload?.sessionId || '_');
const session = (await ref.get()).data();
const result = verifyAssertion({ assertion, session, audience: session?.audience });
if (!result.ok) return res.status(400).json({ error: result.error });
// first-seen sub = new account; returning sub = existing user
const customToken = await getAuth().createCustomToken(result.sub, { kunjiPub: assertion.publicKey });
// result.claims is the user's OPTIONAL, self-asserted profile (or null). It is NOT
// verified — store it in your own profile doc, do NOT put it in the token's claims.
await ref.update({ status: 'approved', sub: result.sub, customToken, claims: result.claims || null });
res.json({ status: 'ok' }); // single-use: status is now 'approved'
});
Showing the user: you get a stable sub, not a verified
name/photo — your app owns the profile. Render a default name + key-sigil avatar with
kunji.handle(sub) (from rp.js); if the user shared
claims, prefer it but treat it as untrusted (HTML-escape the
name, render the picture client-side only). Keep unverified claims out of the custom
token; store them in your own users/{sub} record.
getSessionStatus — the widget's poll
The widget polls this read-only endpoint until the session is approved, then fires success.
export const getSessionStatus = onRequest({ cors: true }, async (req, res) => {
const snap = await db.collection('loginSessions').doc(String(req.query.sessionId || '_')).get();
if (!snap.exists) return res.status(404).json({ error: 'unknown_session' });
const s = snap.data();
res.json({ status: s.status, sub: s.sub || null, customToken: s.customToken || null, claims: s.claims || null });
});
Firestore rules + TTL
Writes are server-only (Admin SDK bypasses rules). The frontend reads its own session
by random id; the doc holds no secrets. Add a Firestore TTL policy on the
ttl field so sessions self-delete.
match /loginSessions/{sessionId} {
allow get: if true; // read your own session by id; no client writes
}
Finish the sign-in (frontend)
With the widget, listen for success and exchange the custom token for a real Firebase
session keyed by sub.
import { getAuth, signInWithCustomToken } from 'firebase/auth';
// the widget's success event carries the RP poll payload, incl. customToken
document.addEventListener('kunji:success', async ({ detail }) => {
await signInWithCustomToken(getAuth(), detail.customToken); // request.auth.uid === sub
});
-
Session known, not expired,
status === 'pending'. -
challengematches the stored challenge. -
audienceequals your own domain. -
Signature verifies over
canonicalJson(signedPayload). -
sub === hex(SHA-256(utf8(publicKeyBase64))), recomputed server-side. -
timestampwithin ±2 minutes. - Session marked consumed — reject re-submission.
A complete working copy lives in the kunji repo under
examples/kunji-login-demo
(these snippets are adapted from it). See the
stack-agnostic protocol guide for message formats
and the trust model.