Skip to content

client.auth

Authentication is exposed through client.auth.zk. See the Authentication guide for usage.

interface AuthUser {
username: string;
/** Decimal-string Poseidon commitment — the user's stable id. */
commitment: string;
}
register(params: {
username: string;
password: string;
email?: string | null;
login?: boolean; // default: true
}): Promise<AuthUser>

Derives the identity, registers the commitment, and (unless login: false) signs in. Throws if the username is taken.

login(username: string, password: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

Re-derives the identity, proves it against a fresh server challenge, and establishes the session and identity. rememberMe: true requests a longer-lived session (30 days). Throws on bad credentials / expired challenge, VaultUnavailableError when the vault can’t be reached, and VaultProtocolMismatchError when the vault refuses this SDK’s OPRF protocol (update the app).

restore(): Promise<AuthUser | null>

Validates a persisted session token (from the sessionStore). Returns the user when valid, or null (clearing a stale token). Does not unlock the identity — call unlock() for encryption/messaging.

resume(): Promise<{ user: AuthUser | null; unlocked: boolean }>

The boot helper: call it once on app start. Runs restore() and, when the account has a passkey that works on this origin, silently unlocks the identity with WebAuthn. user is null when there is no valid session. When unlocked is false (no usable passkey, the user cancelled, or the browser needed a gesture), prompt for the password and call unlock().

unlock(password: string): Promise<void>

Re-derives the identity for an already-authenticated (restored) session. Verifies the password by matching the derived commitment to the session’s; throws on mismatch (incorrect password (commitment mismatch)) and when there is no session to unlock. Throws VaultUnavailableError if the vault can’t be reached.

enrollPasskey(opts?: { rpId?: string; rpName?: string; label?: string }): Promise<void>

Adds a WebAuthn passkey (PRF) that wraps the master seed. Signed-in only. rpId defaults to the current host and label to "Passkey". Passkeys are bound to the origin they were enrolled on.

loginWithPasskey(username: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

Passwordless sign-in via an enrolled passkey. Unwraps the seed and re-derives the identity, establishing session and identity. Throws PasskeyOriginError when the account’s passkey belongs to a different origin — treat it as “no passkey here” and fall back to another factor.

passkeyAvailable(): boolean

Whether WebAuthn is usable in this browser (sync).

passkeyPrfAvailable(): Promise<boolean | null>

Whether the authenticator supports the required PRF extension. null means it can’t be determined. Gate the passkey UI on a true result.

enrollRecoveryPhrase(): Promise<string>

Returns a 24-word BIP39 phrase to display once — it is the seed, nothing is stored server-side. Deterministic, so it returns the same words each call. Signed-in only.

recoverWithPhrase(username, mnemonic, opts?)

Section titled “recoverWithPhrase(username, mnemonic, opts?)”
recoverWithPhrase(username: string, mnemonic: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

“Forgot password” path: re-derives the identity from the phrase and signs in. Follow with changePassword() to set a new password.

enrollEmailFactor(email: string): Promise<{
email: string;
confirm: (code: string) => Promise<void>;
}>

Add an email as a recovery + login factor. Sends a one-time code to the address and returns a confirm continuation — call it with the code from the inbox to finish enrolling:

const { confirm } = await client.auth.zk.enrollEmailFactor("me@example.com");
await confirm(codeFromInbox);

Signed-in only (the seed must be held). Verification-gated: the server only releases the OPRF evaluation after the address is OTP-verified. Muhkoo holds the keys behind that evaluation, so it could technically recover the seed of any account with an email factor. Hosted sign-in discloses this; a custom enroll UI must show equivalent disclosure — see the custody trade-off.

recoverWithEmail(username: string, opts?: { rememberMe?: boolean }): Promise<{
confirm: (code: string) => Promise<AuthUser>;
}>

“Forgot password” path via an enrolled email factor. Sends a code to the stored address (the response never reveals whether one exists), then confirm(code) unwraps the seed and signs in. Follow with changePassword().

const { confirm } = await client.auth.zk.recoverWithEmail("alice");
const user = await confirm(codeFromInbox);
enrollGoogleFactor(idToken: string): Promise<void>

Link a Google account as a recovery + login factor. idToken is a fresh Google ID token obtained via Google Identity Services (the SDK never renders Google UI). Signed-in only. Same custody trade-off as email: Muhkoo could technically recover the seed of an account with a Google factor, so a custom enroll UI must disclose it.

loginWithGoogle(username: string, idToken: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

Sign in with Google — verifies the ID token, unlocks the seed via the gated OPRF evaluation, and establishes session and identity. The account must have linked the factor first (enrollGoogleFactor). Serves both “Sign in with Google” and “recover with Google”.

registerWithGoogle(username: string, idToken: string): Promise<AuthUser>

Register a brand-new, passwordless account whose only factor is Google. A random master seed is generated and wrapped under the Google-gated key; the user can add a password / passkey / phrase later. ZK registration is identical to a password signup. Like any gated factor, Google leaves the seed recoverable by Muhkoo — see the custody trade-off.

changePassword(newPassword: string): Promise<void>

Re-wraps the unchanged seed under a new password. Identity and commitment never move. Signed-in only.

listFactors(): Promise<Array<{
id: string;
type: string; // "password" | "passkey" | "phrase-marker" | "email" | "google" | "device"
label?: string;
createdAt?: number;
masked?: string;
rpId?: string; // passkeys: the origin it was enrolled on
}>>

Lists the user’s enrolled login methods (metadata only). Gated factors (email / google) carry a masked display hint (e.g. "m•••@gmail.com"); the full value is never returned. device entries are paired browsers and machines — an account has one password but may hold many of those.

removeFactor(id: string): Promise<void>

Removes an enrolled factor. Throws if it’s the last remaining one.

recover(): Promise<boolean>

Silently re-mints a session token by re-proving the in-memory identity — no password prompt. Resolves true if it succeeded, false if it can’t (the identity isn’t in memory, e.g. after a reload that kept only the token). The client calls this automatically when a request hits 401; you normally use the client-level recoverSession() + onSessionExpired instead of calling it directly.

if (await client.auth.zk.hasPasskeyForThisOrigin()) { /* offer passkey sign-in */ }

Whether this account has a passkey that will actually work here. Passkeys are per-origin, so an account with a passkey enrolled on another host has one that WebAuthn will refuse on this one. Use this before offering a passkey button — not listFactors(), which will happily report a passkey you cannot use.

A device factor gives a browser or a machine long-lived access to the account without a password and without storing the seed anywhere: a CLI that shouldn’t prompt on every command, a server-side tool, or a browser whose owner asked to be remembered. The hosted sign-in page’s “Keep me signed in on this browser” enrolls one of these — see Staying signed in.

const { factorId, deviceKey } = await client.auth.zk.enrollDevice("ci-runner");

Generates a random key, wraps the master seed under it, and stores only the ciphertext in your vault as a device factor. The key is returned for you to persist; it is never sent anywhere.

The split is the point: the seed itself never lands on disk, and revoking the factor makes the stored key inert immediately — which a copied seed can never be, because nothing can reach out and un-store it.

label is what the owner reads when deciding which pairing to remove ("ci-runner", "Chrome on macOS"), so make it something they’ll recognise.

Treat deviceKey as the credential it is, and store it with the tightest permissions available — on the origin or the machine that will use it, and nowhere else.

unlockWithDevice(username, factorId, deviceKey)

Section titled “unlockWithDevice(username, factorId, deviceKey)”
await client.auth.zk.unlockWithDevice(username, factorId, deviceKey);

Fetches the wrapped seed and opens it locally. Establishes the encryption identity only — pair it with a session for calls that need one. That suits a caller already holding a valid session token on disk, such as a CLI; a browser usually wants loginWithDevice below.

Throws DeviceRevokedError when the pairing has been revoked. That is a different failure from a wrong key, which fails to decrypt rather than to resolve — a machine whose access was withdrawn should be able to say so, not merely report a decryption failure.

loginWithDevice(username, factorId, deviceKey, opts?)

Section titled “loginWithDevice(username, factorId, deviceKey, opts?)”
loginWithDevice(
username: string,
factorId: string,
deviceKey: string,
opts?: { rememberMe?: boolean },
): Promise<AuthUser>

The same unwrap as unlockWithDevice, followed by a real sign-in: the ZK identity is derived from the recovered seed and proved against a fresh challenge, exactly as recoverWithPhrase does. Establishes session and identity. The seed has always been sufficient to authenticate, so asking for a password here would have proved nothing new.

Use it wherever a session can lapse while the pairing is still valid — a browser, most obviously, which would otherwise hold the means to decrypt everything and no way to make an authenticated call. This is what makes signing in once per browser possible with no credential at rest: nothing stores the seed, only a key that opens a blob the vault holds.

Throws DeviceRevokedError on a revoked pairing. Discard the stored key and fall back to a password — see Errors.

Requires @muhkoo/connect@0.14.0-alpha.5 or later, as does DeviceRevokedError and login’s prompt option below.

await client.auth.zk.listDevices(); // [{ id, label, createdAt }]
await client.auth.zk.revokeDevice(id); // Promise<boolean>: whether it existed

Every device factor on the account, newest first — remembered browsers and paired machines alike, metadata only. Revocation takes effect on the next unlock: the factor is gone from the vault, so loginWithDevice / unlockWithDevice throw DeviceRevokedError rather than returning a seed. The same pairings appear as Paired browser or machine in the account’s login methods on the hosted security page, and in muhkoo devices ls.

client.auth.zk.unlockWithSeed(process.env.MUHKOO_SEED);

The inverse of seedBase64, for hosts that hold the seed themselves. Without it, anything outside a browser must run a full ZK login — circuit download and proof — on every invocation.

Sets the encryption identity only; it does not authenticate. A persisted seed is not scoped and cannot be revoked server-side the way a session token or a device factor can, so prefer enrollDevice where you have the choice.

logout(): Promise<void>

Clears the session, identity, and persisted token.

GetterTypeDescription
client.auth.zk.userAuthUser | nullCurrent user (sync).
client.auth.zk.tokenstring | nullCurrent session token (sync).
client.auth.zk.identityZkIdentity | nullDerived identity, or null when locked.
client.auth.zk.seedBase64string | nullMaster seed as base64, or null when locked. Wrap per-user app data to this so it survives password changes and passkey login.
client.auth.userAuthUser | nullConvenience alias.
import {
VaultUnavailableError, VaultProtocolMismatchError, DeviceRevokedError, PasskeyOriginError,
} from "@muhkoo/connect";
  • VaultUnavailableError — thrown by login / unlock when the per-user vault is unreachable (network / 5xx / rate-limit). Distinct from a wrong password — tell the user to retry rather than re-enter credentials.
  • VaultProtocolMismatchError — thrown by login / unlock / register / changePassword when the vault refuses the OPRF protocol this SDK speaks (HTTP 409 oprf_protocol_unsupported). Retrying cannot help: update the app (or the server). Not a wrong password, and not a connection problem.
  • PasskeyOriginError (.code === "passkey_wrong_origin") — thrown by loginWithPasskey when the account’s passkey was enrolled for another origin.
  • DeviceRevokedError — thrown by loginWithDevice / unlockWithDevice when the device factor is no longer in the vault. Typed because the right response is specific: discard the stored key and fall back to a password. Retrying cannot succeed, and re-pairing silently would undo a revocation someone performed on purpose.

Centralized hosted auth on auth.muhkoo.dev (authorization-code + PKCE). See the Hosted authentication guide.

  • login({ appId, redirectUri, redirect?, prompt?, returnTo? }) — Promise<string>. Stashes a PKCE verifier + state and redirects the browser to the hosted /authorize page; resolves to the URL (also returned for popup/test use). redirectUri must be registered for the app (portal → App Detail → Hosted sign-in). redirect: false returns the URL without navigating. prompt: "login" forces the credential screen even on a browser the hosted page remembers — which is how your app’s own sign-out survives. Named after the OIDC parameter that solves the same problem. returnTo is a same-origin path (starts with /, not //) that rides the PKCE stash and comes back from handleCallback(); anything else throws.
  • handleCallback() — Promise<{ username, commitment, returnTo? }>. On your callback route: verifies state, exchanges the code for a session, unseals the master seed from the URL fragment, establishes the session + identity, and scrubs the URL. returnTo is the value passed to login(), when still a same-origin path. Throws on a bad/expired code or PKCE/CSRF mismatch.
  • isCallback() — boolean. true when the current URL is a hosted-auth callback (?code&state). Use it to guard handleCallback().
  • manageAccount({ returnUri? }) — Promise<string>. Redirect to centralized account & security management (auth.muhkoo.dev/security), where the user manages passkeys, recovery email, Google, recovery phrase, and password. returnUri (default: the current URL) becomes a “Back to app” button. Wire it to a “Manage account” / settings entry in your app — there’s no per-app security UI to build.
  • completeAuthorize({ appId, redirectUri, codeChallenge, state }) — Promise<string>. Hosted-page side only (used by auth.muhkoo.dev itself, not by apps): seals the in-memory seed, mints the grant, and returns the URL to send the browser back to.

A browser that ticked “Keep me signed in on this browser” on the hosted page resumes without a password on its next visit: silently for Muhkoo-owned surfaces, behind a “Continue as …” confirmation for every other app. There is nothing to call — it happens inside /authorize. The pairing is a device factor, and prompt: "login" is how you bypass it.

Set authBaseUrl on the Client to override the hosted origin (default https://auth.muhkoo.dev).

Sign-in for keyboard-less devices (TVs, set-top boxes). Requires @muhkoo/connect@0.10.13-alpha.0 or later. See the TV & device pairing guide for the full flow, the storage caveats, and the error table.

Device side:

  • startDevicePairing({ appId, label? }) — Promise<DevicePairingSession>. Generates the ephemeral keypair and returns { userCode, verificationCode, verificationUri, verificationUriComplete, expiresAt, interval, deviceCode, privateKey, devicePublicKeyB64 }. Display userCode and verificationCode.
  • waitForDevicePairing(session, { signal?, onTick? }) — Promise<{ username, commitment }>. Polls to completion (with slow_down backoff), unseals the seed, verifies the commitment, mints the device’s own session, and persists it. Rejects with DevicePairingError.
  • pollDevicePairing(session) — Promise<DevicePairingPoll>. One poll, for custom loops, resolving to a union on status: pending (interval, expiresIn), slow_down (interval, retryAfter), approved (user), denied or expired.
  • cancelDevicePairing(session) — void. Abandons the attempt; an in-flight waitForDevicePairing rejects with reason aborted.
  • resumeDeviceSession() — Promise<{ username, commitment } | null>. Cold start from the persisted identity. null means “pair again”; never throws.
  • hasDeviceSession() — Promise<boolean>. Whether anything is persisted.
  • forgetDeviceSession() — Promise<void>. Wipes the persisted seed, the device identity key, and the session.

Approver side (signed in, seed unlocked):

  • lookupDevicePairing(userCode) — Promise<DevicePairingRequest>. Resolves a typed code to { userCode, devicePublicKeyB64, verificationCode, deviceFingerprint, deviceLabel, appId, appLabel, isKnownDevice, requiresReauth, expiresAt }.
  • approveDevicePairing(req, { label? }) — Promise<{ deviceId }>. Seals the seed to the device’s key and posts it. Throws ReauthRequiredError when a new device needs a fresh sign-in factor.
  • denyDevicePairing(userCode) — Promise<void>. Refuse. No fresh factor needed.
  • listPairedDevices() — Promise<PairedDevice[]> — { id, label, appId, pairedAt, lastSeenAt, activeSession }.
  • revokePairedDevice(id) — Promise<void>. Removes the device from the roster so it must be re-approved. It does not end the device’s current session (that survives until expiry) and does not take back the encryption key the device already holds.

Errors: DevicePairingError (.reason, .code, .status, .retryAfter, .details) and ReauthRequiredError (extends it; adds .authAgeSeconds, .maxAgeSeconds), both exported from @muhkoo/connect.