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.
| Export | Entry | What it does |
|---|---|---|
createIamClient<typeof iam>(options) | @better-iam/client | Creates the typed client: client.{group}.{method}(input) for every API method, plus authorize, authorizeMany, listAccessible, and $request |
IamClientError (alias ClientError) | @better-iam/client | The error every failed call throws, with the server's stable code, the HTTP status, and retry details |
createSessionStore(client, { initial? }) | @better-iam/client/session | A framework-agnostic store for the current session, which the React, Vue, and Svelte bindings share |
isUnauthenticated(error) | @better-iam/client/session | True when an error means "no usable session" (401 or 403) rather than a transport failure |
| Passkey helpers | @better-iam/client/passkeys | The 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/device | Signs 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/clientOr 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.
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:
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 switcherCalling 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, andclient.listAccessibletake their input directly, without the credential fields. - Methods without input.
authmethods that take no input, such asgetSession,signOut,listSessions,revokeOtherSessions,listPasskeys,beginPasskeyRegistration,regenerateRecoveryCodes,disableMfa,listTrustedDevices,revokeTrustedDevices, andmfaStatus, take only the call options. - Per-call options. The last argument is
{ signal?, headers? }: anAbortSignalto 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 IAMbasePath. 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.
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 tenantRoute: 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.
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).
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.
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 likesignInwhen a second factor is required. - Browser-session cookies. The session cookie normally lasts as long as the session. Send
X-Better-IAM-Persistent: 0on the call that issues a session, for exampleclient.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 asAuthorization: Bearerand 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.
| Field | Meaning |
|---|---|
code | The server's error code (INVALID_CREDENTIALS, ACCESS_DENIED, RATE_LIMITED, ...), or a client code below |
message | The server's message |
status | The HTTP status; 0 for errors raised before a request was sent |
retryAfterMs | For RATE_LIMITED and QUOTA_EXCEEDED: how long to wait, from the error body or the Retry-After header |
requestId | The X-Request-Id the request carried, when requestId is enabled |
region, location | For WRONG_REGION: the region that serves the organization, and its sign-in URL there when one can be built |
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 badbaseURL,basePath, orrequestId, or no Fetch implementation.INVALID_TOKEN: a bearertokenthat 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.
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();| Member | Does |
|---|---|
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:
| Helper | What 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" |
WebAuthnAbortService | Cancels 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.
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.
| Framework | Package | Main exports | Guide |
|---|---|---|---|
| React | @better-iam/react | IamProvider, useSession, useAuthorize, useAccessible, Can, useIamClient | React |
| Next.js App Router | @better-iam/next/client | IamNextProvider, useSession, useSignOut, useRouterSync, SignInForm | Next.js |
| Vue and Nuxt | @better-iam/vue | createIam, useSession, useCan, useAuthorize, IamCan | Vue |
| Svelte and SvelteKit | @better-iam/svelte | createIam, setIamContext, getIamContext | SvelteKit |
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:
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.
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
IamProvider and hooks on top of this client.
The Vue plugin and composables.
API referenceEvery group and method the client can call.
Error handlingCodes, statuses, and which failures to retry.
TypeScriptHow typeof iam types the client, and the types it exports.
The methods the client calls, and how credentials work on the server.
Better IAM is created by Sean Filimon
Last updated
Server API
The betterIam() instance and its iam.api groups, how every method takes a credential and an input, how to get the credential from a request, and how the same methods are served over HTTP.
CLI
What the better-iam command does, how it finds your configuration, how it signs in and keeps profiles, the commands you will use most, and how to add project commands.