BetterIAM

Typed client

createIamClient gives browser and Node code a typed API inferred from your server instance, with error handling, a shared session store, passkey helpers, and device proofs.

@better-iam/client is the browser-safe way to call Better IAM. It depends on no server runtime. You import your configured server instance as a type, so the client knows every API group, method, input, and result, while database and authentication code stay out of the browser bundle. Every framework integration builds on it, and the server-side helpers use the same client with an in-process transport.

Why not fetch the HTTP API yourself? You could, but you would repeat the wire format on every call: the route for each method, the JSON envelope, the X-Better-IAM header the server requires, cookie credentials, and error parsing. You would also lose the types, which are inferred from your own server instance, so a renamed method or a wrong input fails at compile time instead of in production.

The client only makes calls. It does not read sessions inside your server's request handlers, guard routes, or check where incoming requests come from; a server integration does those.

ExportEntryWhat it does
createIamClient<typeof iam>(options)@better-iam/clientCreates the typed client: client.{group}.{method}(input) for every API method, plus authorize, authorizeMany, listAccessible, and $request
IamClientError (alias ClientError)@better-iam/clientThe error every failed call throws, with the server's stable code, the HTTP status, and retry details
createSessionStore(client, { initial? })@better-iam/client/sessionA framework-agnostic store for the current session, which the React, Vue, and Svelte bindings share
isUnauthenticated(error)@better-iam/client/sessionTrue when an error means "no usable session" (401 or 403) rather than a transport failure
Passkey helpers@better-iam/client/passkeysThe browser side of WebAuthn registration and sign-in, kept out of the main entry so importing the client does not bundle them
createDeviceProver(options?)@better-iam/client/deviceSigns proofs that a request comes from a registered device, sent as the x-better-iam-device header

The umbrella package exposes the same entries as better-iam/client, better-iam/client/session, better-iam/client/passkeys, and better-iam/client/device. The client also exports types: ClientOptions (the options below), ClientCallOptions (per-call options), IamClient<typeof iam> (the client's full type, for props and context), ClientApi (the per-group method map), and ClientTransport (the $request escape hatch).

Install

npm i @better-iam/client

Or install the umbrella package, better-iam, and import from better-iam/client.

Setup

Serve the IAM HTTP API

The client talks to the IAM handler mounted in your application, /api/iam by default. Each framework page shows how to mount it: Next.js, Nuxt, SvelteKit, React Router, NestJS, or Express, Hono, and Fastify. A plain Node server can use createServer(iam.nodeHandler).

Create one client

Import the server instance with import type, so nothing from it reaches the bundle, and create the client once for the application.

lib/iam-client.ts
import { createIamClient } from '@better-iam/client';
import type { iam } from './iam.js';

export const client = createIamClient<typeof iam>({
  baseURL: 'https://identity.example.com',
  onUnauthenticated: () => window.location.assign('/login'), // lapsed or revoked session, never a wrong password
  retryRateLimited: true, // retry once after the server's Retry-After when it is short
  requestId: true, // X-Request-Id on every call; IamClientError.requestId for support tickets
});

Call the API

Each API method hangs off its group, and inputs and results are typed. A login screen might resolve the organization, sign in, and then ask which buttons and projects to show:

app/login.ts
import { client } from '../lib/iam-client.js';

const { tenantId } = await client.tenants.lookup({ slug: 'acme' }); // public alias discovery for login screens
await client.auth.signIn({ tenantId, email, password });
const current = await client.auth.getSession();

const { results } = await client.authorizeMany({
  tenantId,
  checks: [{ action: 'projects:manage', resource: { type: 'project', id } }],
});
const { resources } = await client.listAccessible({
  tenantId,
  action: 'projects:read',
  type: 'project',
});
const accounts = await client.links.list(); // linked identities in other organizations, for an account switcher

Calling the API

The client mirrors iam.api: client.{group}.{method}(input, options?). Server methods take a credential as their first argument; the client drops it, because the browser's is the cookie (or the bearer token you configure). Everything else about a method's input and result is inferred from typeof iam.

  • Top-level checks. client.authorize, client.authorizeMany, and client.listAccessible take their input directly, without the credential fields.
  • Methods without input. auth methods that take no input, such as getSession, signOut, listSessions, revokeOtherSessions, listPasskeys, beginPasskeyRegistration, regenerateRecoveryCodes, disableMfa, listTrustedDevices, revokeTrustedDevices, and mfaStatus, take only the call options.
  • Per-call options. The last argument is { signal?, headers? }: an AbortSignal to cancel the call, and headers merged over the configured ones.
  • Plugin routes. client.$request<Result>('plugins/my-plugin/action', input) calls any path relative to the IAM basePath. See Call a plugin endpoint.

Every request is POST {baseURL}{basePath}/{group}/{method} with the input as a JSON body, Content-Type: application/json, and X-Better-IAM: 1. The last two are always set, because the server's CSRF (cross-site request forgery) boundary requires them. JSON mutations must carry X-Better-IAM: 1, and cookie requests must also come from an exact trusted Origin. A page on another site cannot add custom headers to a plain form post, so it cannot make the browser call the API with your visitor's cookies.

Requests use credentials: 'include' (so the session cookie goes along), redirect: 'error', and cache: 'no-store'. The server answers { data } or { error: { code, message } }, and the client returns data or throws. The HTTP API guide covers the wire format, cookies, and trusted origins.

Check permissions for the UI

authorize, authorizeMany, and listAccessible answer as the signed-in caller. Use them to decide which buttons, menu entries, and list items to show; the server still checks every operation.

app/document-actions.ts
import { client } from '../lib/iam-client.js';

const { allowed } = await client.authorize({
  tenantId,
  action: 'documents:delete',
  resource: { type: 'document', id: documentId },
});
const { results } = await client.authorizeMany({
  tenantId,
  checks: [
    { action: 'documents:write', resource: { type: 'document', id: documentId } },
    { action: 'documents:delete', resource: { type: 'document', id: documentId } },
  ],
}); // at most 50 checks, all in one tenant

Route: POST /authorize, POST /authorizeMany, POST /listAccessible

Cancel a call

Pass an AbortSignal in the per-call options. An aborted call rejects with the signal's reason (an AbortError by default), not with an IamClientError.

app/search.ts
import { client } from '../lib/iam-client.js';

const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const people = await client.identities.list({ tenantId, query: 'ali' }, { signal: controller.signal });

Route: POST /identities/list

Call a plugin endpoint

Plugin endpoints are not part of the instance's type, so $request takes the result type as a type argument. The path is relative to basePath, and each segment must be a plain name (INVALID_PATH otherwise).

app/projects.ts
import { client } from '../lib/iam-client.js';

const result = await client.$request<{ archived: boolean }>('plugins/projects/archive', { tenantId, projectId });

Route: POST /plugins/{pluginId}/{path} (the plugin and path above are illustrative)

Sign-in flows

auth.signIn returns either a session ('token' in result) or an MFA challenge ({ mfaRequired: true, challenge, enrollmentRequired, emailCodeAvailable?, passkeyAvailable? }). Finish the challenge with a method it allows: an authenticator code, first-time enrollment, an emailed code (requestMfaCode), or a passkey.

app/sign-in.ts
import { client } from '../lib/iam-client.js';

const result = await client.auth.signIn({ tenantId, email, password });
if ('mfaRequired' in result) {
  if (result.enrollmentRequired) {
    // First sign-in that must enroll an authenticator.
    const { uri } = await client.auth.beginMfa({ tenantId, challenge: result.challenge });
    showQrCode(uri);
    await client.auth.confirmMfa({ credential: { tenantId, challenge: result.challenge }, code });
  } else {
    await client.auth.verifyMfa({ tenantId, challenge: result.challenge, code });
  }
}

During an authenticated session, enroll with client.auth.beginMfa() and client.auth.confirmMfa({ code }). MFA lists every second-factor path, and Sign-in methods covers passwordless and federated sign-in.

  • Invitations. Member invitations are redeemed without a session: client.identities.acceptInvitation({ tenantId, token, name, password }) creates the account, applies the invited roles, and signs the person in, or returns an MFA challenge like signIn when a second factor is required.
  • Browser-session cookies. The session cookie normally lasts as long as the session. Send X-Better-IAM-Persistent: 0 on the call that issues a session, for example client.auth.signIn(input, { headers: { 'x-better-iam-persistent': '0' } }), to get a cookie that ends when the browser closes ("keep me signed in" left unticked).
  • Bearer sessions. For service or bearer sessions, pass token: () => currentToken. The client sends it as Authorization: Bearer and never writes tokens to browser storage.

Options

Prop

Type

onUnauthenticated deliberately ignores credential failures such as INVALID_CREDENTIALS and codes the server also raises during sign-in or step-up (MFA_REQUIRED, EMAIL_UNVERIFIED, IP_BLOCKED, and others). The client cannot tell those apart from a dead session, and a trip to the login page in the middle of a would lose the person's work. Handle them per call.

Errors

Every refusal from the server, and every problem the client detects itself, is an IamClientError (also exported as ClientError). It preserves the server's stable code, so your UI can branch on it, and never exposes the raw response body. Network failures and aborted calls reject with Fetch's own error (such as a TypeError or an AbortError) unchanged.

FieldMeaning
codeThe server's error code (INVALID_CREDENTIALS, ACCESS_DENIED, RATE_LIMITED, ...), or a client code below
messageThe server's message
statusThe HTTP status; 0 for errors raised before a request was sent
retryAfterMsFor RATE_LIMITED and QUOTA_EXCEEDED: how long to wait, from the error body or the Retry-After header
requestIdThe X-Request-Id the request carried, when requestId is enabled
region, locationFor WRONG_REGION: the region that serves the organization, and its sign-in URL there when one can be built
app/sign-in.ts
import { IamClientError } from '@better-iam/client';
import { client } from '../lib/iam-client.js';

try {
  await client.auth.signIn({ tenantId, email, password });
} catch (error) {
  if (error instanceof IamClientError && error.code === 'RATE_LIMITED')
    showMessage(`Try again in ${Math.ceil((error.retryAfterMs ?? 0) / 1000)} seconds`);
  else throw error;
}

The client raises its own codes for problems it detects:

  • INVALID_CONFIG: a bad baseURL, basePath, or requestId, or no Fetch implementation.
  • INVALID_TOKEN: a bearer token that is neither an opaque token (16 to 512 letters, digits, _, and -) nor an IAM-signed session JWT.
  • INVALID_PATH: a route segment that is not a plain name.
  • INVALID_INPUT: input that is not serializable JSON.
  • INVALID_RESPONSE: a response body that is not the IAM envelope.
  • HTTP_ERROR: a non-2xx response without an error envelope.

The error reference lists the server's codes.

Session store

A UI needs one shared answer to "who is signed in?" that every component sees and that updates everywhere at once after a sign-in, a sign-out, or an expired session. @better-iam/client/session holds that answer. The React, Vue, and Svelte bindings subscribe to it, and it works with plain scripts or any other framework.

session.ts
import { createIamClient } from '@better-iam/client';
import { createSessionStore } from '@better-iam/client/session';
import type { iam } from './iam.js';

const client = createIamClient<typeof iam>();
const store = createSessionStore(client); // pass { initial: session } or { initial: null } after server rendering

store.subscribe(() => {
  const { status, session, error } = store.getSnapshot();
  render(status === 'authenticated' && session ? session.identity.name : status); // session is typed Session | null
});
await store.refresh();
MemberDoes
getSnapshot(){ status, session, error, updatedAt }; status is loading, authenticated, unauthenticated, or error
subscribe(listener)Calls listener on every change; returns the unsubscribe function
refresh()Reloads the session from the server; concurrent calls share one request
signOut()Signs out on the server, then clears the local session even if the server call failed (the failure is still thrown to the caller)
set(session)Replaces the local session, for example right after a sign-in response

Without initial, the store starts as loading; initial: null means known signed-out. A refresh that fails with a 401 or 403 moves to unauthenticated and keeps the server's error, so its code can be shown. Any other failure is a transport error: the status becomes error and the last known session is kept. isUnauthenticated(error) applies the same rule, and also recognizes the server's own errors when a store runs on the server with an in-process client. The module exports the types SessionClient, SessionOf, SessionSnapshot, SessionStatus, and SessionStore.

Passkeys

A ceremony has two halves. The server issues a challenge (options) and later verifies the signed response; the browser asks the authenticator (a phone, a security key, Touch ID, Windows Hello) to create or use a credential in between. @better-iam/client/passkeys provides the browser half by re-exporting these helpers from @simplewebauthn/browser:

HelperWhat it does
startRegistration({ optionsJSON })Runs the browser's "create a passkey" prompt with the options from auth.beginPasskeyRegistration and returns the response to send to auth.finishPasskeyRegistration
startAuthentication({ optionsJSON, useBrowserAutofill? })Runs the "use a passkey" prompt with options from beginPasskeyAuthentication or beginPasskeyMfa. With useBrowserAutofill, the prompt waits in the username field's autofill list instead of opening a dialog (the page needs an input with autocomplete="username webauthn")
browserSupportsWebAuthn()Whether the browser supports passkeys at all; check it before showing passkey buttons
browserSupportsWebAuthnAutofill()Whether passkeys can be offered through autofill (resolves to a boolean)
platformAuthenticatorIsAvailable()Whether the device has a built-in authenticator, a good moment to suggest "add a passkey"
WebAuthnAbortServiceCancels a pending ceremony (cancelCeremony()), for example a waiting autofill request before a button-started one

It also exports the RegistrationResponseJSON, AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, and PublicKeyCredentialRequestOptionsJSON types.

Registration needs a recently authenticated session.

app/passkeys.ts
import { startRegistration } from '@better-iam/client/passkeys';
import { client } from '../lib/iam-client.js';

const challenge = await client.auth.beginPasskeyRegistration();
const response = await startRegistration({ optionsJSON: challenge.options });
await client.auth.finishPasskeyRegistration({
  challengeId: challenge.challengeId,
  response,
  name: 'Work laptop', // optional
});

Passkeys covers relying-party configuration, discovery, and passkey management (listPasskeys, renamePasskey, deletePasskey).

The server decides

The server authenticates and authorizes every call the client makes. Permission results from authorize, authorizeMany, and listAccessible are : use them to decide what to show, and enforce on the server.

Framework bindings

The UI packages take the client you create and add one shared session store, hooks or composables, and permission components on top of it. Create the client once, outside render, and hand it to the binding.

FrameworkPackageMain exportsGuide
React@better-iam/reactIamProvider, useSession, useAuthorize, useAccessible, Can, useIamClientReact
Next.js App Router@better-iam/next/clientIamNextProvider, useSession, useSignOut, useRouterSync, SignInFormNext.js
Vue and Nuxt@better-iam/vuecreateIam, useSession, useCan, useAuthorize, IamCanVue
Svelte and SvelteKit@better-iam/sveltecreateIam, setIamContext, getIamContextSvelteKit
src/app.tsx
import { IamProvider, useSession } from '@better-iam/react';
import { client } from './iam-client';

function Greeting() {
  const { status, session } = useSession<typeof client>();
  return status === 'authenticated' && session ? <p>Signed in as {session.identity.name}</p> : null;
}

export function App() {
  return (
    <IamProvider client={client}>
      <Greeting />
    </IamProvider>
  );
}

Calling from server code

On the server you normally call iam.api directly with the request's credential; see Server API. The server integrations also give each request an in-process client (req.iam.client in Express, c.get('iam').client in Hono, request.iam.client in Fastify) whose fetch calls iam.handler without a network hop. Use it when the HTTP handler's behaviour matters, such as signing a browser in from a form post: the handler sets the session cookie, and the integration copies it onto your response. See per-request helpers.

A Node process that calls a remote IAM deployment, such as a worker or a CLI tool, creates the client with baseURL and a bearer token:

worker/iam-client.ts
import { createIamClient } from '@better-iam/client';
import type { iam } from '../server/iam.js';

export const client = createIamClient<typeof iam>({
  baseURL: 'https://identity.example.com', // required outside a browser
  token: () => process.env.BETTER_IAM_TOKEN, // an API key; read on every call, never stored
  requestId: true,
});

Device proofs

When an organization requires registered devices, requests carry a proof signed by a key that never leaves the device. @better-iam/client/device creates the key (non-extractable, in IndexedDB in browsers) and signs proofs; pass its headers() to the client's headers option.

lib/iam-client.ts
import { createIamClient } from '@better-iam/client';
import { createDeviceProver } from '@better-iam/client/device';
import type { iam } from './iam.js';

const prover = createDeviceProver({ onError: (error) => console.warn('No device proof', error) });
let sessionId: string | undefined;

export const client = createIamClient<typeof iam>({
  headers: () => prover.headers(sessionId), // { 'x-better-iam-device': proof } once the session id is known
});

export async function refreshDevice() {
  sessionId = (await client.auth.getSession()).session.id;
}

A device problem never fails the request: headers() then sends no proof, which the server treats as "no device". Device posture covers enrollment with devices.enroll and the policies that use proofs.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page