@zautha/js
Framework-agnostic browser SDK for Zautha: sessions, tokens, sign-in and sign-up
flows, and mounting the prebuilt components from @zautha/ui.
Install
Section titled “Install”npm i @zautha/js @zautha/uiQuickstart
Section titled “Quickstart”Copy your publishable key from the Zautha dashboard (Overview or API keys).
import { Zautha } from "@zautha/js";import { ui } from "@zautha/ui";import "@zautha/ui/styles.css";
const zautha = new Zautha("pk_test_…", { ui });await zautha.load();
if (zautha.isSignedIn) { zautha.mountUserButton(document.getElementById("user-button"));} else { zautha.mountSignIn(document.getElementById("sign-in"));}Prefer the hosted pages? Without ui, zautha.redirectToSignIn() sends the user to your instance’s
hosted sign-in page and back.
On a development instance, addresses ending in +zautha_test receive no email and verify with 424242.
Two-step verification
Section titled “Two-step verification”A user with two-step verification (an authenticator app or a passkey, user.mfaEnabled) is asked for a second factor
after their password or email code: signIn.status becomes needs_second_factor and signIn.passkey(),
signIn.totp({ code }) or signIn.backupCode({ code }) completes the sign-in (signIn.supportedSecondFactors lists what
the user has). Users manage it with user.createTotp(), user.verifyTotp({ code }), user.createBackupCodes() and
user.disableTotp(). The first method a user adds turns two-step verification on and returns their backup codes
(backupCodes; null for a later method); removing the last one turns it off.
Passkeys
Section titled “Passkeys”A passkey belongs to its RP ID, a domain, and works only on pages whose origin is that domain or one of its subdomains
(over HTTPS, or http://localhost; never an IP address such as 127.0.0.1). zautha.isPasskeySupported() applies this
rule, and the prebuilt components offer passkeys only where it is true:
- Production instance: the RP ID is your domain (
environment.passkeyRpId, e.g.example.comforauth.example.com), so a passkey added in your app onapp.example.comalso works on the hosted pages and the other way round. - Development instance: the RP ID is each page’s own host (
passkeyRpIdisnull). A passkey registered onlocalhostdoes not work on the hosted pages (….accounts.zautha.com), and one registered on the hosted pages does not work onlocalhost.
await zautha.user.createPasskey(); // { passkey, backupCodes }: backup codes when it turned two-step verification on
const { error } = await zautha.signIn.passkey(); // new sign-in, after an identifier, or the second factorif (error === null) await zautha.signIn.finalize();createPasskey asks Zautha for creation options and the browser to use them in the same call, so wrapping it in
withReverification can fail in Safari: by the time the session has verified again, the original click’s user
activation has expired and the browser refuses the ceremony. If your own flow needs a step between reverifying and
the ceremony, call user.preparePasskey() instead (it alone needs the recent verification) and run its result’s
create() from a later, fresh click — this is what the prebuilt UserProfile does. create() is single-use and
expires with the server’s challenge (PasskeyCreation.expiresAt); a second call, or one past that time, fails with
the error passkey_expired (isZauthaPasskeyError) without asking the browser — call preparePasskey() again for
a fresh one.
A passkey is both factors: signing in with one never asks for a second step. For passkeys in the identifier field’s
autofill, give the field autocomplete="username webauthn", check await zautha.isPasskeyAutofillSupported() and call
signIn.passkey({ autofill: true, signal }); abort the signal when the user continues with an identifier. A closed
browser dialog returns the error passkey_cancelled (isZauthaPasskeyError), which needs no message.
To reverify with a passkey, start with session.verify().create({ level }), then call session.verify().passkey(): at
needs_first_factor it verifies both factors in one step (completing any level), at needs_second_factor it verifies
the second. user.renamePasskey(id, name) and user.deletePasskey(id) manage them.
The methods that change or remove a way to sign in — user.updatePassword, user.disableTotp,
user.createBackupCodes, user.deletePasskey(id, params), an email address’s setPrimary and destroy, a connected
account’s destroy — take { signOutOfOtherSessions: true } to sign the user out on every other device once the change
succeeds (this session stays). The prebuilt UserProfile asks with a checkbox, checked by default.
user.getAuthorizedApps() lists the OAuth apps (not first-party) the user allowed to access their account, by name:
clientId, name, scopes, grantedAt and lastUsedAt. Each has revoke(), which needs a recent verification and
ends the app’s grants and tokens; its next sign-in asks for consent again. UserProfile shows them under
Authorized apps in its Security tab.
When your instance requires two-step verification, a user without it gets a pending session with the task
setup_mfa: isSignedIn is false and getToken() resolves to null until the task is done. user.createTotp() and
user.verifyTotp({ code }) complete it on the server; show the returned backup codes, then call
zautha.session.reload(), which makes the session active in every tab (until then your app stays signed out, so it
doesn’t move on while the codes are on screen). The prebuilt SignIn and SignUp components do all of this themselves.
Social sign-in
Section titled “Social sign-in”The providers your instance enabled are in environment.socialProviders (google, github, microsoft, discord,
gitlab, apple). Signing in or up with one leaves for the provider and comes back to a page of yours that finishes
it:
// On the sign-in page:await zautha.signIn.authenticateWithRedirect({ strategy: "oauth_google", redirectUrl: "/sso-callback", // the page below redirectUrlComplete: "/dashboard", // where to go once signed in});
// On /sso-callback:const result = await zautha.handleRedirectCallback();handleRedirectCallback() completes the sign-in; once the session is active it goes to redirectUrlComplete. It
stays on the page when something is left: a pending session (e.g. two-step verification to set up), the second
factor or missing fields — finish them there, then go on. A provider account no user has
yet becomes a sign-up (with the provider’s verified email and names, never a password), and a sign-up with an account
that belongs to a user signs that user in. result.status tells what is left: needs_second_factor (the user has
two-step verification: continue with zautha.signIn), missing_requirements (e.g. a username, or an email code when
the provider did not verify the address: continue with zautha.signUp), failed (result.error, e.g.
oauth_access_denied when the user declined at the provider). The prebuilt <AuthenticateWithRedirectCallback/>
(@zautha/ui) does all of this on one page.
A signed-in user connects more providers with user.createExternalAccount({ strategy, redirectUrl }) (needs a recent
verification; handleRedirectCallback() on redirectUrl finishes it with status: "connected") and disconnects one
with user.externalAccounts[i].destroy(). A session connects one provider at a time: of two connections in flight
at once (e.g. in two tabs), the one that comes back last replaces the other’s waiting result, so at most one finishes
and the other fails (form_code_incorrect or resource_not_found) — both do when the replaced one finishes first.
Organizations
Section titled “Organizations”When the instance has organizations on (state.environment.organizations.enabled), a session has an active
organization or none (the personal account): session.lastActiveOrganizationId, and state.organization — id,
slug, role (org:admin) and permissions (the role’s custom permissions, org:invoices:read) as the session token
says. setActive({ organization }) changes it: an organization id, its slug while the instance has slugs on, or null
for the personal account. It resolves once a token with the new organization is in hand; the listeners and the other
tabs follow. It rejects with organization_not_found (no such organization, or the user is not its member) or
organizations_disabled.
await zautha.setActive({ organization: "org_…" });checkAuthorization(zautha.state.organization, { permission: "org:invoices:read" }); // true or falsecheckAuthorization is the has rule of every Zautha SDK: full keys, from the token alone, false without an active
organization; a system permission (org:sys_…) is never in the token, so it is false too. Your server decides from the
same token (@zautha/backend, @zautha/nextjs, Zautha.AspNetCore).
Self-service and invitations
Section titled “Self-service and invitations”zautha.createOrganization({ name, slug? }) creates one with the user as a member (the instance’s creator role) and
zautha.getOrganization(id) reads one the user is in; both give an OrganizationResource with update, destroy,
reload, getMemberships, updateMember, removeMember, inviteMember({ emailAddress, role? }),
inviteMembers({ emailAddresses, role? }) (up to 10), getInvitations({ status? }) (each invitation has revoke()) and
getRoles(). Each needs the role’s permission (missing_organization_permission otherwise). On the user:
organizationMemberships, getOrganizationMemberships(), getOrganizationInvitations() (each has accept()),
acceptOrganizationInvitation(invitationId) and leaveOrganization(id). Lists resolve to { data, totalCount }
(limit 1–100, default 10, and offset). A write reloads zautha.user here and in the other tabs; accepting, leaving
or deleting the active organization also refreshes the token.
When the instance requires a membership (environment.organizations.membershipRequired), a new session without one is
pending with the task choose_organization (after setup_mfa, when both are left) and gets no token: create an
organization, accept an invitation or setActive one the user is in — the session then becomes active and signed in
here and in the other tabs. setActive({ organization: null }) is refused there (personal_accounts_not_allowed), and so
is leaving or deleting the user’s last organization.
An invitation link opens the hosted login’s /invitation page, which signs the user in or up with the link’s ticket:
signIn.create({ strategy: "ticket", ticket }) and signUp.create({ strategy: "ticket", ticket, password }) (the
address is the invitation’s, verified already).
Bot protection
Section titled “Bot protection”On a production instance every sign-up, and a sign-in after repeated failed attempts, takes a bot check (Cloudflare
Turnstile). Development instances never ask for one. The SDK runs the check in a frame from
https://accounts.zautha.com and sends its token with the request; most visitors never see it. Starting a sign-up
(signUp.create(), signUp.authenticateWithRedirect()) waits for the token, and a sign-in the server answers with
captcha_required is sent once more with one.
- Content-Security-Policy: if your pages send one, it must allow the frame:
frame-src https://accounts.zautha.com. Without it the check cannot load and the call fails with aZauthaCaptchaError(isZauthaCaptchaError). - Placement: in your own form, put
<div id="zautha-captcha"></div>above the submit button; the check appears there when it needs the user. Without that element the SDK opens it in an overlay (and warns once in the console). The prebuiltSignInandSignUpcomponents place it themselves. - Errors:
captcha_invalid(the check was not accepted, try again) andcaptcha_unavailable(the check is down for the moment) come back as API errors like any other; a check that could not run at all is aZauthaCaptchaErrorwhosecodeistimeout(no answer from the frame within 15 seconds, usually the CSP),unsupported(the browser cannot run the check),closedorreplaced(the check’s frame was removed or swapped for another step’s before it finished) or Turnstile’s own error code.
Cookies on your app’s host
Section titled “Cookies on your app’s host”So your server can read the session (see @zautha/backend), the SDK writes __Host-zautha_session (the
session token, 60 seconds, renewed with every token) on your app’s own host and removes it on sign-out; Zautha writes
__Secure-zautha_client_uat on your domain. A production instance accepts https origins only, so these prefixed
cookies always work. On a development instance (http://localhost cannot carry a prefix) the SDK writes
__zautha_session_{suffix}, __zautha_client_uat_{suffix} and __zautha_dev_client_{suffix}, where the short
per-instance suffix keeps two dev apps on localhost from sharing a session. Cookies are Secure except on
http://localhost and http://127.0.0.1.
After a sign-in on the hosted pages, a development instance hands the client credential back to your page in the URL
fragment (#__zautha_dev_client=…&__zautha_dev_state=…), and an email sign-in link carries its token the same way
(#__zautha_link_token=…&__zautha_link_attempt=…): a fragment never reaches your server or its logs. The SDK reads
them on load() and removes only its own parameters; the rest of your fragment stays.
Impersonation
Section titled “Impersonation”In an impersonation session (your support team signed in as the user with an actor token, or a dashboard admin with
“Impersonate”), zautha.session.actor is { sub, iss }: sub is the id your server gave when it created the actor
token, or the dashboard member’s (user_…); iss is the dashboard’s origin for one started there, else null. For the
user’s own session it is null. With ui from @zautha/ui, the SDK shows a band at the bottom of every page of such a
session (“You are signed in as … (impersonation).” with “End session”); it cannot be turned off. Without ui (headless use) there is no band, but actor is still set: show your own notice
from session.actor. The hosted login’s
/impersonate page signs in with the link’s ticket (signIn.create({ strategy: "impersonation", ticket })).
An impersonation session can read, mint tokens, switch organizations, sign out and start a new sign-in; any other write
(profile, email addresses, password, two-step verification, passkeys, connected accounts, organizations, OAuth consent)
is 403 impersonation_forbidden. It never passes a reverification check (its token’s fva is [-1, -1]), and
user.getSessions() marks such a session with impersonation: true (no browser, system, device or address).