Authentication
All auth lives under client.auth.zk. Identity is derived from
(username, password) on the device and proven with a zero-knowledge proof —
see Identity & app keys for the model.
Register
Section titled “Register”const user = await client.auth.zk.register({ username: "ada", password: "correct horse battery staple", email: "ada@example.com", // optional});By default register also signs the user in (returns { username, commitment }
and establishes a session). Pass login: false to register without signing in.
Log in
Section titled “Log in”const user = await client.auth.zk.login("ada", "correct horse battery staple");// { username: "ada", commitment: "1481…" }On success the client holds both the session token and the derived identity, so storage and messaging work immediately.
await client.auth.zk.login("ada", pw, { rememberMe: true }); // longer-lived sessionRestore a session on reload
Section titled “Restore a session on reload”With a persisted sessionStore,
call restore() on boot. It validates the stored token with the server and
returns the user, or null (clearing a stale token):
const user = await client.auth.zk.restore();if (user) { // Authenticated — token-gated calls (most of storage) work.}Current user & sign out
Section titled “Current user & sign out”client.user; // { username, commitment } | null (sync)client.isAuthenticated; // boolean (sync)
await client.auth.zk.logout(); // clears session + identity + persisted tokenMulti-device
Section titled “Multi-device”Because identity is deterministic in (username, password), signing in on a
second device reproduces the same identity and commitment — the user
transparently sees the same data. There’s no key export/import step.
Add a passkey
Section titled “Add a passkey”Once signed in, enroll a WebAuthn passkey so the user can sign in without a password. The passkey wraps the same master seed that backs the identity, so the commitment never moves.
if (client.auth.zk.passkeyAvailable() && (await client.auth.zk.passkeyPrfAvailable())) { await client.auth.zk.enrollPasskey({ label: "MacBook Touch ID" });}Sign in with a passkey
Section titled “Sign in with a passkey”Passwordless sign-in. The authenticator unwraps the seed, re-deriving the same identity as a password login — storage and messaging work immediately.
const user = await client.auth.zk.loginWithPasskey("ada", { rememberMe: true });// { username: "ada", commitment: "1481…" }Recovery phrase
Section titled “Recovery phrase”Enroll a 24-word BIP39 recovery phrase as a “forgot password” fallback. The phrase is the seed — nothing is stored server-side — so show it once and let the user write it down.
const mnemonic = await client.auth.zk.enrollRecoveryPhrase();// "vivid spider ... (24 words)" — display once, never persistTo recover, the user enters their phrase. This signs them in; immediately have them set a new password:
const user = await client.auth.zk.recoverWithPhrase("ada", mnemonic);await client.auth.zk.changePassword("a fresh strong password");Email & Google factors
Section titled “Email & Google factors”Email and Google are verification-gated factors: unlike a password or passkey, they carry no client secret, so the server only releases the seed after the user proves control of the channel (an emailed one-time code, or a verified Google ID token). They’re the mainstream “I have my email / my Google account, get me back in” recovery paths — and the same machinery powers “Sign in with Google”.
Enrolling an email sends a one-time code, then a confirm continuation finishes:
const { confirm } = await client.auth.zk.enrollEmailFactor("me@example.com");await confirm(codeFromInbox); // signed-in onlyRecovering with email is the “forgot password” path — it sends a code to the stored address and signs the user in; follow with a new password:
const { confirm } = await client.auth.zk.recoverWithEmail("ada");const user = await confirm(codeFromInbox);await client.auth.zk.changePassword("a fresh strong password");Google works the same way. Obtain a fresh ID token via Google Identity Services (the SDK never renders Google UI), then link, sign in, or register passwordless:
await client.auth.zk.enrollGoogleFactor(idToken); // link (signed-in)await client.auth.zk.loginWithGoogle("ada", idToken); // sign in / recoverawait client.auth.zk.registerWithGoogle("ada", idToken); // new passwordless accountChange your password
Section titled “Change your password”The password is a factor that wraps the seed — not the source of keys. Changing it re-wraps the unchanged seed, so the identity and commitment never move and no data needs re-encrypting.
await client.auth.zk.changePassword("a fresh strong password");Manage login methods
Section titled “Manage login methods”List the user’s enrolled factors (metadata only) and remove ones they no longer want:
const factors = await client.auth.zk.listFactors();// [{ id, type: "password" | "passkey" | "phrase-marker" | "email" | "google",// label?, createdAt?, masked? }, …] // masked: "m•••@gmail.com" for email/google
await client.auth.zk.removeFactor(factors[0].id);When the vault is unreachable
Section titled “When the vault is unreachable”login and unlock read the wrapped seed from a server-blind per-user vault. If
the vault can’t be reached (network blip, 5xx, rate-limit) they throw
VaultUnavailableError — distinct from a wrong password. Surface “try again”
rather than asking the user to re-enter their password:
import { VaultUnavailableError } from "@muhkoo/connect";
try { await client.auth.zk.login("ada", pw);} catch (err) { if (err instanceof VaultUnavailableError) { // Transient — tell the user to retry, don't blame the password. }}Handling errors
Section titled “Handling errors”Login throws on failure; the message tells you what to surface:
try { await client.auth.zk.login(username, password);} catch (err) { const msg = (err as Error).message; if (/commitment mismatch|incorrect password/.test(msg)) { // Wrong password — or a legacy/incompatible account. } else if (/User not found/.test(msg)) { // No account with that username. } else if (/challenge/i.test(msg)) { // Auth challenge expired/replayed — just retry. }}See client.auth reference for the full method list.
Hosted authentication
Section titled “Hosted authentication”Instead of embedding the login UI and the proving circuits (~6 MiB) in your
app, redirect to the Muhkoo-hosted sign-in page at auth.muhkoo.dev. It
owns registration, sign-in, recovery, and every factor (password, passkey,
email, Google) — then calls your app back with a session and the master seed.
This is the simplest path for most apps, and it means “Sign in with Google”
works with no per-app OAuth origin setup — Google only ever sees one origin.
Embedded zero-knowledge auth (client.auth.zk, above) stays available when you
want the login UX fully in-app.
- The user clicks “Continue with Muhkoo” in your app.
client.auth.hosted.login(...)redirects toauth.muhkoo.dev/authorizewith PKCE + a CSRFstate.- The user signs in / registers / recovers on the hosted page.
- The hosted page redirects back to your
redirectUriwith a one-timecode(and the sealed-seed key in the URL fragment). client.auth.hosted.handleCallback()exchanges the code, unseals the seed, establishes the session, and scrubs the URL.
Register your callback URL once in the developer portal (App Detail → Hosted sign-in) — exact match, https only (localhost may use http), no wildcards. Copy your app id.
// Start sign-in (e.g. on a button click):await client.auth.hosted.login({ appId: "<your-app-id>", redirectUri: "https://yourapp.com/auth/callback",});
// On your callback route:if (client.auth.hosted.isCallback()) { const { username, commitment } = await client.auth.hosted.handleCallback(); // session + identity established — same as embedded auth from here on}authBaseUrl on the Client overrides the hosted origin (default
auth.muhkoo.dev; set auth.staging.muhkoo.dev for staging).
Sealed-seed handoff
Section titled “Sealed-seed handoff”The master seed can’t be put on the wire server-readable, so the hosted page seals it under a one-time key carried only in the URL fragment (which never reaches a server) and hands back a single-use code. Your app exchanges the code (PKCE) and decrypts the seed with the fragment key, then strips it. It’s trust-equivalent to deriving the key in your app — only the credential entry moved to the Muhkoo origin.
Identity vs. session. A restored session and an unlocked identity differ.
handleCallback()establishes both; after a page reload the session restores but the seed isn’t persisted, so gate “signed in” onclient.auth.zk.identityand prompt a one-tap re-sign-in when it’s missing.
See client.auth reference for client.auth.hosted.
Account & security management
Section titled “Account & security management”Don’t build a per-app security/settings screen. Point a “Manage account” button at the hosted page and it handles everything — passkeys, recovery email, Google, recovery phrase, change password, and removing login methods — consistently across every Muhkoo app:
await client.auth.hosted.manageAccount({ returnUri: window.location.href });// → auth.muhkoo.dev/security; the page has a "Back to app" button.The user signs in there to unlock their identity (the master seed isn’t persisted at rest), then manages factors. Changes apply to the same identity your app already uses — nothing to sync.