Skip to content

Authentication

All auth lives under client.auth.zk. The identity is derived on the device from a master seed — which the password (or another factor) unlocks from a per-user vault — and proven with a zero-knowledge proof. See Identity & app keys for the model.

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. It throws if the username is taken.

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 session

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.
}

resume() does restore() and, when the account has a passkey that works on this origin, also unlocks the identity with it. Call it once on app start:

const { user, unlocked } = await client.auth.zk.resume();
if (user && !unlocked) {
// Session restored, identity locked — ask for the password, then unlock().
}
client.user; // { username, commitment } | null (sync)
client.isAuthenticated; // boolean (sync)
await client.auth.zk.logout(); // clears session + identity + persisted token

Signing in on a second device unwraps the same master seed from the vault, so it reproduces the same identity and commitment — the user transparently sees the same data. There’s no key export/import step.

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" });
}

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…" }

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 persist

To 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 and Google are verification-gated factors: unlike a password or passkey, they carry no user-held secret. The wrapped seed is released, by keys Muhkoo holds, only 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 only

Recovering 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 / recover
await client.auth.zk.registerWithGoogle("ada", idToken); // new passwordless account

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");

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" | "device",
// label?, createdAt?, masked?, rpId? }, …] // masked: "m•••@gmail.com" for email/google
await client.auth.zk.removeFactor(factors[0].id);

device entries are remembered browsers and paired machines (see Staying signed in). Removing one takes effect immediately — that browser or machine holds only a key to a blob the vault stores, so withdrawing the blob withdraws the access.

login and unlock read the wrapped seed from the 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.
}
}

A vault that refuses the OPRF protocol this SDK speaks throws VaultProtocolMismatchError instead — that one never resolves by retrying; the app (or server) needs updating. Passkey sign-in throws PasskeyOriginError when the account’s passkey belongs to another origin; fall back to another factor.

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.

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.

  1. The user clicks “Continue with Muhkoo” in your app.
  2. client.auth.hosted.login(...) redirects to auth.muhkoo.dev/authorize with PKCE + a CSRF state.
  3. The user signs in / registers / recovers on the hosted page.
  4. The hosted page redirects back to your redirectUri with a one-time code (and the sealed-seed key in the URL fragment).
  5. 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, returnTo } = await client.auth.hosted.handleCallback();
// session + identity established — same as embedded auth from here on
}

To land the user back where they started, pass a same-origin path as returnTo to login() (returnTo: "/settings"); handleCallback() hands it back. Anything that isn’t a same-origin path (starting with /, not //) is refused.

authBaseUrl on the Client overrides the hosted origin (default https://auth.muhkoo.dev; set https://auth.staging.muhkoo.dev for staging).

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” on client.auth.zk.identity and prompt a one-tap re-sign-in when it’s missing.

See client.auth reference for client.auth.hosted.

The master seed is memory-only by design: nothing writes it to disk. The cost is that a fresh page load has nothing to restore, so without help every reload, every restored tab, and every link from one app to another means typing the password again. Two apps sharing a registrable domain doesn’t help either — localStorage is per-origin, and the seed isn’t in it anyway.

The hosted page solves that on its own origin. Sign-in offers “Keep me signed in on this browser” — opt-in, unchecked by default, because a durable credential should be something the user chose. Ticking it enrolls a vault device factor: a random key is generated in the browser, the master seed is wrapped under it, and only the ciphertext goes to the vault. The key stays on auth.muhkoo.dev, where no app can read it.

Something durable does now sit on that browser, and that is the trade being made deliberately. What sits there is a key that is useless without the vault’s half, revocable from anywhere, and dropped after 30 days — none of which a stored seed could ever be, since nothing can reach out and un-store a secret already on a disk.

What the user sees on that second visit depends on who is asking:

Redirect targetWhat happens
A Muhkoo-owned surface (the portal, the IDE)Silent hand-off — the page completes and redirects with nothing shown.
Your appA “Continue as ada?” screen: no password, but a deliberate click, plus a “Use a different account” escape.

Being remembered must never mean that any app the user has registered can take the seed unannounced, which is what that confirmation buys. The server decides which category a redirect URI falls in (GET /api/auth/first-party), and the decision is UX only — the grant endpoint validates redirect URIs independently, so a wrong answer costs a click, never a boundary.

This is a different thing from rememberMe on login(), which asks for a longer-lived session token. A session token can’t decrypt anything; a device pairing restores the seed as well, which is why it can stand in for signing in.

Single sign-on means the hosted page still knows the user after your app has signed them out locally. Pass prompt: "login" to ask for credentials anyway:

await client.auth.zk.logout(); // clears this app's session + identity
// …later, when the user clicks "Sign in" again:
await client.auth.hosted.login({
appId: "<your-app-id>",
redirectUri: "https://yourapp.com/auth/callback",
prompt: "login", // don't resume the remembered pairing
});

Without it, the click right after a sign-out resumes the remembered browser and lands the same user straight back in your app — to them, the sign-out looks like it did nothing. Use it for “sign out”, “switch account”, and any re-authentication step you want to be real. Omit it everywhere else, which is most places.

prompt needs @muhkoo/connect@0.14.0-alpha.5 or later. Everything above it on this page is the hosted page’s own behaviour and needs no particular SDK version.

Paired browsers sit alongside paired machines (the CLI pairs itself on the first command that needs your files, not at muhkoo login) in the account’s login methods on auth.muhkoo.dev/security, labelled Paired browser or machine, and can be removed from there or with muhkoo devices rm <factorId>. Removal is immediate: the browser’s key is useless without the wrapped seed the vault stores.

The 30-day lapse is local to the browser, so a pairing that has aged out stops working but stays in that list until someone removes it. An old entry there is normal, not a sign that something is still signed in.

If you build your own sign-in page instead of using the hosted one, the same primitives are on client.auth.zk — enrollDevice(label) to pair, and loginWithDevice(username, factorId, deviceKey) to sign in with the stored key (unlockWithDevice recovers the seed only, which is right for a CLI that already holds a session token). You then own the key: keep it on your origin, and handle DeviceRevokedError by discarding it and falling back to a password.

A TV or set-top box can’t run the redirect flow — no keyboard, no WebAuthn, and no redirect URL to come back to. Hosted auth extends to those devices with device pairing: the device shows a short code, the user approves it from a browser where they’re already signed in, and the same sealed-seed handoff delivers the master seed to that device alone (the seal key is derived by ECDH instead of riding a URL fragment, and the redirect becomes a poll).

See the TV & device pairing guide.

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.